Este documento registra as decisões arquiteturais do projeto, o raciocínio por trás delas, e os padrões de design que guiam o desenvolvimento. É a referência de "por quê", não de "como" (para isso, ver
CLAUDE.md).
O Qualia Coding é uma plataforma de Análise Qualitativa de Dados (QDA) construída como plugin do Obsidian. A premissa central:
Anotar dados qualitativos em 6 formatos diferentes com um sistema de códigos unificado, depois analisar padrões cross-format com 19+ visualizações analíticas e um Research Board.
- Notes stay 100% clean — Arquivos do vault nunca são modificados. Todas as anotações vivem em
data.json. CM6 decorations cuidam da visualização. O vault é um vault de notas, não um banco de dados. - Global workspace as state zero — O usuário codifica primeiro, organiza em projetos depois. Análogo à filosofia Obsidian: "loose notes first, folders later".
- One code system, many formats — Um único
CodeDefinitionRegistrycompartilhado entre todos os 7 engines. Markers referenciam códigos por ID estável (codeId), não por nome — rename é atômico no registry sem propagação. - Non-invasive file intercept — Cada engine intercepta seu formato sem conflitar com handlers nativos do Obsidian ou outros plugins.
Cada formato de dado tem necessidades fundamentalmente diferentes de renderização, interação e coordenadas:
| Engine | Renderização | Coordenadas | Lib externa |
|---|---|---|---|
| Markdown | CM6 Decorations | line/ch (texto) | — (CM6 nativo) |
| DOM overlays + SVG | page + CSS % (bottom-left → top-left) | PDF.js (via Obsidian) | |
| CSV | AG Grid cells | row/column + char offsets | AG Grid + PapaParse |
| Image | Fabric.js canvas | normalized 0-1 (resolution-independent) | Fabric.js 6.9 |
| Audio | WaveSurfer waveform | seconds (float) | WaveSurfer.js 7 |
| Video | WaveSurfer + <video> |
seconds (float) | WaveSurfer.js 7 |
| Analytics | Chart.js + Fabric.js | N/A (read-only) | Chart.js + Fabric.js |
Cada um tem um CodingModel próprio mas todos implementam a interface SidebarModelInterface, permitindo uma sidebar unificada.
| Engine | View base | Método de associação | Por quê |
|---|---|---|---|
| Markdown | MarkdownView (nativo) |
registerEditorExtension() |
Integração direta com CM6 — é o editor nativo |
| CSV | FileView custom |
registerExtensions(['csv', 'parquet']) |
Obsidian não tem handler nativo para tabular |
| (viewer nativo) | Instrumentação via MutationObserver | Reusa o viewer nativo do Obsidian | |
| Image, Audio, Video | FileView custom |
registerFileIntercept (active-leaf-change) |
Obsidian já trata nativamente png/mp3/mp4; registerExtensions joga exceção nessas ext |
Regra: NUNCA usar registerExtensions para png/jpg/mp3/mp4/webm/... — Obsidian já tem handler nativo e joga Error: Attempting to register an existing file extension, quebrando o plugin no onload.
FileView lifecycle: as 4 views custom (CSV, Image, Audio, Video) estendem FileView. Ao setViewState({type, state: {file}}) — seja por user click, command, interceptor ou restore — Obsidian dispara automaticamente onLoadFile(file) / onUnloadFile(file). Views usam this.file: TFile padrão (sem campos custom tipo currentFile).
Cada uma das 4 mídias tem 2 settings simétricas no namespace do engine:
autoOpen(defaultfalse) — abre em Coding View (ou, pro PDF, instrumenta). Quandofalse, cai no viewer nativo do Obsidian sem decoração do plugin.showButton(defaulttrue) — adiciona botãoreplace-allno header viaview.addAction, que alterna entre os dois modos.
Setting global general.openToggleInNewTab (default false) controla se o toggle via botão/command substitui a aba atual ou abre em nova. Não se aplica ao PDF (toggle é sempre in-place).
Assimetria arquitetural escondida do usuário:
- Image/Audio/Video têm views custom → toggle faz
leaf.setViewStateentre tipo nativo (image,audio,video) e tipo coding (qualia-image-coding,qualia-audio-view,qualia-video-view). - PDF não tem view custom — sempre o PDF viewer nativo do Obsidian. Toggle liga/desliga instrumentação (observer + decorators + drawing) in-place via
plugin.togglePdfInstrumentation(view), exposto porsrc/pdf/index.ts. Scroll e página preservados, sem reload.
Intercept pin per (leaf, file): com autoOpen=true, o interceptor re-intercepta active-leaf-change. Quando o user toggla manualmente, o markLeafHandled(leaf, filePath) registra o pin em pinnedFileByLeaf — o interceptor respeita aquele par (leaf, file) até o user abrir arquivo diferente. Pin é resetado no clearFileInterceptRules (onunload) pra não sobreviver hot-reload.
Commands padronizados: toggle-image-coding / toggle-audio-coding / toggle-video-coding / toggle-pdf-coding — 4 commands bidirecionais, mesma lógica do botão.
Módulos:
src/core/mediaViewTypes.ts— constantes isoladas (string-only, sem imports de Obsidian), pra permitir unit tests em jsdom sem arrastar o grafo de views.src/core/viewToggleHelpers.ts— lógica pura:resolveToggleTarget(currentViewType, mediaKind),isMediaViewType(viewType). 11 unit tests.src/core/mediaToggleButton.ts— injeção viaactive-leaf-change+performToggleCommand(reusado pelos 4 commands).
Problema: Decoration.widget() insere elementos inline no texto, causando reflow de word-wrap em linhas longas.
Decisão: Handles renderizados em um overlay div no scrollDOM, posicionados com coordsAtPos + requestMeasure.
Trade-off: Listeners de eventos precisam ser separados (overlay não recebe eventos do contentDOM). Mais complexidade, mas zero impacto visual.
Problema: Como mostrar N códigos em um mesmo trecho? "Cor é 1 dimensão, códigos são N dimensões. Não existe forma perfeita de mostrar N informações categóricas no mesmo pixel."
Decisão: Margin panel com barras verticais por código — padrão de toda ferramenta QDA profissional (ATLAS.ti, NVivo, MAXQDA, Dedoose).
Análise comparativa:
- ATLAS.ti — margin bars coloridas por código, tooltip no hover
- NVivo — "coding stripes" no gutter, múltiplas colunas
- MAXQDA — barras verticais com labels, draggable para resize
- Dedoose — chips de cor ao lado do texto
- Taguette — só cor de fundo, sem gutter
Análise:
- Modal: ~100-150 LOC, mais simples. Limita: sem drag-and-drop, sem hierarquia visual, sem inline edit.
- Sidebar (ItemView): ~200-300 LOC. Suporta: hierarquia de códigos, drag-and-drop reorder, merge, export, statistics, toggle de visibilidade. Escala para todas as features futuras.
Decisão: Sidebar é o investimento certo a longo prazo. Quick Switcher (Cmd+Shift+C) complementa para acesso rápido.
| Feature | Sidebar | Modal | Quick Switcher |
|---|---|---|---|
| Drag-and-drop reorder | ✅ | ❌ | ❌ |
| Code hierarchy visual | ✅ | ❌ | ❌ |
| Inline rename | ✅ | ❌ | ❌ |
| Merge codes (drag onto) | ✅ | ❌ | ❌ |
| Export from UI | ✅ | ❌ | ❌ |
| Statistics inline | ✅ | ❌ | ❌ |
| Filter/toggle visibility | ✅ | ✅ | ❌ |
Problema: Persistência de dados — JSON vs SQLite.
Decisão: JSON + índices in-memory.
Razões:
- SQLite quebra no Obsidian mobile
- Complica distribuição via community plugins
- JSON é suficiente para o volume de dados de QDA (centenas/milhares de markers, não milhões)
- Caminho de migração mantido aberto se escala justificar
Design central:
QualiaData {
registry → CodeDefinition[] (compartilhado)
general → GeneralSettings (showMagnitudeInPopover, showRelationsInPopover, …)
markdown → markers, settings
pdf → markers, shapes
csv → segmentMarkers, rowMarkers
image → markers, settings
audio → files[{path, markers}], settings
video → files[{path, markers}], settings
}
DataManager é o único ponto de acesso:
section(engine)lê,setSection(engine, data)grava- Debounce de 500ms no save para evitar thrashing
- Merge automático de defaults no load (campos novos não quebram dados existentes)
- Migrações de 3 formatos legacy do registry
História:
- Approach A (Obsidian Menu) —
Menunativo do Obsidian. Funciona bem, mas CM6 perde a seleção visual quando foco vai pro menu. - Approach B (CM6 HTML Tooltip) — Tooltip do CM6 com HTML puro. Resolve a perda de seleção, mas styling é manual.
- Approach C (CM6 + Obsidian Components) — ATIVO. Tooltip do CM6 com
TextComponent/ToggleComponentnativos. Lê CSS vars do Obsidian, aplica como inline styles.
O "dark mode breakthrough": CSS variables do Obsidian não cascatam para dentro de tooltips CM6 (DOM apartado). Solução: getComputedStyle(document.body) lê os valores resolvidos, copia como inline styles + custom properties no container do tooltip.
Regra: NUNCA modificar Approach A (obsidianMenu.ts) ou Approach B (cm6TooltipMenu.ts). São fallbacks preservados.
Problema: PDF usa origem bottom-left, CSS usa top-left.
Solução: placeRectInPage() espelha eixo Y via viewBox[3] - rect[y] + viewBox[1], normaliza para [left, top, right, bottom], expressa como CSS percentages.
Dual-path highlight: (a) chars-level via item.chars (customização do PDF.js no Obsidian) para bounding preciso; (b) DOM Range fallback via getBoundingClientRect(). Garante funcionamento mesmo se a customização do PDF.js mudar.
Decisão: Todas as coordenadas de regiões são normalizadas relativas às dimensões naturais da imagem (0-1). Markers são resolution-independent — renderizam corretamente em qualquer zoom ou tamanho de canvas.
- Ellipses armazenadas como bounding-box rect coords com
shape: 'ellipse'— denormalizadas para rx/ry no render. - Polígonos: Fabric.js armazena pontos relativos à origem da shape. Precisa usar
calcTransformMatrix()para obter coordenadas absolutas antes de normalizar.
Coordenadas: from/to em seconds (float).
Overlapping: Algoritmo greedy de lane assignment — ordena por start time depois duração descendente, atribui primeira lane onde laneEnd <= marker.from. CSS top e height como percentages.
Minimap: Overlay divs com posicionamento percentual. Largura mínima de 0.3% para segments muito curtos.
Video vs Audio: Video tem <video> element acima do waveform. WaveSurfer usa o elemento de vídeo como media source. videoFit: 'contain' | 'cover' controla letterbox vs crop.
UnifiedModelAdapter merge N engines em 1 SidebarModelInterface:
- Delega writes para o engine owner via type detection
- Type guards via
markerTypediscriminante:isPdfMarker(),isImageMarker(),isCsvMarker(),isAudioMarker(),isVideoMarker() - Um único
UnifiedCodeExplorerView+UnifiedCodeDetailViewpara todos os formatos - Cache com dirty flag:
getAllMarkers(),getMarkersForFile(),getMarkerById(),getAllFileIds()retornam dados cacheados. Dirty flag global invalidado no wrapper doonChange. IndicesMap<fileId>eMap<id>construidos emrebuild()—getMarkersForFileegetMarkerByIdsão O(1) - Views com rAF debounce: Explorer e Detail usam
scheduleRefreshcomrequestAnimationFramepara coalescer mudanças rápidas em 1 rebuild DOM
Problema: Se o menu de codificação cria um marker ao abrir e o usuário cancela, sobra um marker vazio ("fantasma").
Solução: Dois métodos separados:
findExistingMarker()— read-only, para hover/displayfindOrCreateMarker()— cria sob demanda, só quando o primeiro código é toggled ON
Todos os engines implementam hover bidirecional:
- View hover →
model.setHoverState(markerId, codeName)→ sidebar highlights - Sidebar hover →
model.setHoverState(markerId, codeName)→ view highlights
No markdown: setHoverEffect compartilhado entre markerViewPlugin e marginPanelExtension. Payload inclui hoveredIds?: string[] para multi-marker hover. isInPartialOverlap flag previne dispatches redundantes em zonas de overlap.
| Engine | Prefixo |
|---|---|
| Markdown | codemarker- |
codemarker-pdf- |
|
| CSV | csv- / codemarker-csv- |
| Image | codemarker-image- |
| Audio | codemarker-audio- |
| Video | codemarker-video- |
| Analytics | codemarker-analytics- |
Zero colisões confirmadas entre engines.
Elementos renderizados fora do DOM do Obsidian (CM6 tooltips, WaveSurfer, Fabric.js) não herdam CSS variables.
Solução: applyThemeColors() lê getComputedStyle(document.body), copia como inline styles. Listener no evento css-change para live theme switching.
| Evento | Payload | Ação |
|---|---|---|
qualia-pdf:navigate |
— | Não implementado — PDF abre via #page=N, sem scroll a marker específico |
qualia-image:navigate |
{file, markerId} |
Pan to region + flash glow |
qualia-csv:navigate |
{file, row, column} |
ensureIndexVisible + flashCells |
qualia-audio:navigate / qualia-video:navigate |
{file, seekTo} |
Seek waveform (sem play automático) |
qualia:clear-all |
(none) | Board/Image/Analytics views clear live state |
Menu de codificação unificado via CodingPopoverAdapter interface. O popover opera com nomes (UI layer), e cada adapter resolve name→id na borda:
getActiveCodes(): string[]— retorna nomes (resolvecodeId→ name via registry)addCode(codeName: string): void— resolve name → id viaregistry.getByName(), passa id ao modelremoveCode(codeName: string): void— mesmo pattern de resoluçãogetMemo(): string/setMemo(value: string): voidgetMagnitudeForCode?(codeId: string): string | undefinedsetMagnitudeForCode?(codeId: string, value: string | undefined): voidgetRelationsForCode?(codeId: string): CodeRelation[]setRelationsForCode?(codeId: string, relations: CodeRelation[]): voidsave(): void/onRefresh(): voidonNavClick?(codeName: string, isActive: boolean): void
Seções colapsáveis: Memo (sempre), Magnitude (toggle showMagnitudeInPopover), Relations (toggle showRelationsInPopover). Toggles escondem do popover mas a feature continua acessível no Detail View e Marker Detail.
CSV tem batch mode especial para codificar múltiplas linhas visíveis de uma vez.
Instância única compartilhada entre todos os 7 engines:
- 12 cores auto-palette (alta contrast, safe em light/dark)
- Palette categórica (não gradiente) — cada cor é visualmente distinta
- Markers referenciam códigos por ID estável (
codes: CodeApplication[]ondeCodeApplication = { codeId: string; magnitude?: string }). Rename é atômico no registry — sem propagação para markers - Helpers centralizados em
codeApplicationHelpers.ts:hasCode,getCodeIds,addCodeApplication,removeCodeApplication - Popover adapters resolvem name→id na borda UI (usuário digita nome, adapter resolve para
codeIdvia registry) - Auto-persistence via
onMutatecallback — qualquer mutação (add, rename, delete, recolor) dispara save automaticamente
Hierarquia (Phase A):
parentId?,childrenOrder: string[],mergedFrom?: string[]no CodeDefinition- Metodos:
setParent(com deteccao de ciclo),getRootCodes,getChildren,getAncestors,getDescendants,getDepth rootOrder: string[]controla ordem de exibicao dos codigos rootexecuteMerge()emmergeModal.ts: reassigna markers, reparenta filhos, registra audit trail
parentId como theme hierarchy (NVivo / Braun & Clarke):
A hierarquia parentId NÃO é só organização estrutural — ela É o mecanismo de theme hierarchy do plugin. Um código pai com zero aplicações diretas (ex: Experiencias sem segments, só com filhos resistencia/adocao/frustacao) age como theme; o count agregado inclui aplicações dos filhos via buildCountIndex. Isso é o padrão exato do NVivo (parent/child com opção "Aggregate") e casa com a metodologia Braun & Clarke (códigos → subtemas → temas → temas abrangentes = hierarquia aninhada). Se surgir demanda por "theme hierarchy", o reflexo correto é primeiro perguntar por que parentId não resolve antes de propor nova camada. O item ROADMAP #2a ("Code Groups") é coisa DIFERENTE — grouping flat N:N cross-cutting estilo Atlas.ti, ortogonal à hierarquia.
Pastas virtuais (Phase B + nested):
folder?: stringno CodeDefinition (ID da pasta — folder folha onde o código vive)FolderDefinition { id, name, parentId?, subfolderOrder?, createdAt }armazenado no registry.parentIdaponta pra outra folder (ou root se ausente);subfolderOrderé a ordem de filhos folder dentro do pai- Registry mantém
folderOrder(root) +subfolderOrder(per-parent). CRUD:createFolder(name, parentId?),renameFolder,deleteFolder(cascade — apaga descendants e códigos via_deleteCodeNoEmit),setCodeFolder,setFolderParent(cycle detection walk-up + reuso de_insertInList),getRootFolders,getChildFolders,getFolderAncestors,getFolderDescendants(cycle protection via Set),getCodesInFolder - Pastas NAO afetam hierarquia, analytics, ou queries — sao puramente organizacionais (mesmo aninhadas)
buildFlatTreerecursivo viavisitFolders(simétrico avisitCodes);FlatFolderNode.depthdinâmico controlapadding-leftno row. Search auto-expande folder ancestors- Serializacao:
folders: Record<string, FolderDefinition>+folderOrder: string[]no JSON
Code Groups (Tier 1.5 — flat N:N, ortogonal a parentId e folder):
-
groups?: string[]no CodeDefinition (array de groupIds — código pode ser membro de N groups) -
GroupDefinition { id, name, color, description?, memo?, paletteIndex, parentId? schema-ready, createdAt }armazenado no registry -
GROUP_PALETTE(8 cores pastéis) distinto doDEFAULT_PALETTE(12 cores) pra evitar confusão visual em chip counters -
CRUD:
createGroup,renameGroup,deleteGroup(ripple — remove groupId de todoscode.groups[]),setGroupColor,setGroupDescription,setGroupMemo,setGroupOrder -
Membership:
addCodeToGroup/removeCodeFromGroup(idempotentes — fire único doonMutatelistener) -
Queries:
getCodesInGroup,getGroupsForCode,getGroupMemberCount -
Serialização:
groups,groupOrder,nextGroupPaletteIndexemdata.registry. Tolerante a data.json legado (ausência de campos = inicialização vazia) -
Distinção operacional:
Folder Group 1 código em N? 1 só N ao mesmo tempo Afeta Analytics? ❌ ✅ (filter) Aparece em export? ❌ ✅ (QDPX <Sets>, CSVgroupscolumn +groups.csv)Finalidade Cosmética Dimensão analítica -
UI: painel "Groups" no topo do codebook (chips + filter contextual), chip contador
🏷Nem rows, seção Groups no Code Detail, right-click "Add to group..." -
Analytics:
FilterConfig.groupFiltercommemberCodeIdspre-computed embuildFilterConfig(evita passar registry em 9 callers deapplyFilters); UI single-select com fallback dropdown >10 groups -
Export: QDPX
<Set>dentro de<CodeBook>com custom namespacexmlns:qualia="urn:qualia-coding:extensions:1.0"(qualia:colorround-trip próprio); QDPX externo (Atlas.ti/MAXQDA) semqualia:colorrecebe cor auto-atribuída doGROUP_PALETTEem round-robin;<MemberSource>ignorado com warning. Tabular CSV: colunagroupsemcodes.csv(;-separated names) +groups.csvstandalone -
Merge: target herda union dos groups (snapshot pego antes do delete dos sources — preserva audit trail analítico)
Codebook Panel (UI):
hierarchyHelpers.ts:buildFlatTree(virtual scroll com FlatCodeNode | FlatFolderNode),buildCountIndex(contagem direta + agregada)codebookTreeRenderer.ts: árvore hierárquica viacreateVirtualListhelper (pattern unificado com listas planas, ver §17/§34); folder rows (icone) vs code rows (chevron + swatch)codebookDragDrop.ts: reparent, merge, move-to-foldercodebookContextMenu.ts: Rename, Add child, Move to folder, Merge, Delete (codigos) + Rename, Delete (pastas)mergeModal.ts: FuzzySuggestModal com preview de impacto e destino configuravel
- Cache in-memory + save debounced (500ms)
- Section-based:
dataManager.section('markdown'),setSection('csv', data) - Merge automático de defaults no load
flushPendingSave()noonunload()— garante persistência
Adicionalmente no markdown: Model-level debounce de 2s via markDirtyForSave() (separado do DataManager). flushPendingSave() no unload do model.
Cada engine exporta registerXxxEngine() que retorna EngineRegistration<Model>:
type EngineCleanup = () => void | Promise<void>;
interface EngineRegistration<M> {
cleanup: EngineCleanup;
model: M;
}main.ts (~180 LOC) é o único ponto que conhece todos os engines. Responsabilidades:
- Bootstrap: DataManager, CodeDefinitionRegistry, auto-persist via onMutate
- Registro dos 7 engines (cada um retorna cleanup + model)
- Montagem do UnifiedModelAdapter com adapters de todos os engines
- Cross-engine navigation (label-click, code-click → sidebar detail)
- Sidebar view registration (Code Explorer, Code Detail)
- Cleanup reverso no onunload
Não deve implementar lógica de engine — apenas coordenar. O acoplamento é intencional — um plugin com 7 engines precisa de exatamente 1 ponto que conhece todos. Reavaliar se ultrapassar ~250 LOC.
analytics/data/dataConsolidator.ts expõe 6 funções puras por engine (consolidateMarkdown, consolidateCsv, etc.) + consolidateCodes + consolidate() como composição. Cada função retorna EngineSlice { markers, hasData }. É o único lugar que conhece todos os formatos — isso é feature (consistência cross-engine), não fragilidade.
analytics/data/consolidationCache.ts (ConsolidationCache) envolve o consolidator com cache por engine. Dirty flags por engine (Set<EngineType>) + registryDirty como dimensão separada. Quando um engine muda, só ele é reprocessado — os demais mantêm cache. Wiring no main.ts via invalidateEngine() nos onChange dos models e invalidateRegistry() no addOnMutate do registry.
analyticsView.ts (~340 LOC) gerencia ~22 campos de estado organizados por concern. Cada mode module recebe o ctx via interface tipada (AnalyticsViewContext), sem acessar o view direto. A statefulness é custo inerente de UI sem framework. Se o state crescer além de ~25 campos, agrupar em sub-objetos por concern (ex: wordCloudState: { lang, minLength, maxWords }).
relationsNetworkMode.ts — visualização de rede baseada em relações explícitas (não co-ocorrência). Usa relationsEngine.ts para extrair arestas de CodeDefinition.relations e CodeApplication.relations.
- Níveis: toggle "Code-level | Code + Segments" via
ctx.relationsLevel - Arestas: sólida (code-level), tracejada (segment-level), dash-dot (merged quando mesma aresta nos dois níveis)
- Espessura:
Math.min(1 + weight, 8)— weight = contagem de markers distintos - Direção: arrowheads para relações direcionais
- Hover tooltip: hit-testing point-to-segment no canvas (threshold 6px)
- Hover-focus (2026-04-27): ao passar cursor em nó, edges não-conectadas escurecem (opacity ÷3) — destaque visual sem reflow. State local
hoveredNodeIdxna closure derenderRelationsNetwork; reset em mousemove sem hit, mouseleave e mousedown (drag start). Cálculo da opacity emrelationsNetworkHelpers.computeEdgeOpacity()(puro, testável) - Filtro "Min weight" (2026-04-27): slider no painel de config com label dinâmico "N — showing X/Y edges". Volátil por sessão (
ctx.relationsMinEdgeWeight, default 1). Filtra apenas noredraw()— simulação roda com grafo completo (preserva drag-positions). Event"change"(release), não"input"— evita re-roda da força-direção por pixel arrastado. Clamp defensivo em ambos render paths quandomaxObservedWeightmuda - Dados: lê markers raw via
readAllData(ctx.plugin.dataManager)— não usa consolidated data (relações vivem emCodeApplication, não emConsolidatedData) - CSV export: source, target, label, directed, level, weight
codeMetadataMode.ts — heatmap canvas 2D cruzando códigos qualitativos com Case Variables. Responde "como este código se distribui pelos perfis dos casos?" — pergunta complementar ao chiSquareMode (que responde "este código é específico de um tipo de fonte?").
Pipeline de cálculo é uma função pura em analytics/data/codeMetadata.ts:
applyFilters(data, filters, registry)— markers filtrados (incl. caseVariableFilter atual)- Discovery dos labels de coluna baseado em
registry.getType(variableName):- text/checkbox: valor literal vira coluna
- number:
binNumeric()— ≤4 valores únicos → categórico literal; ≥5 → quartis[min–Q1] (Q1–Q2] (Q2–Q3] (Q3–max] - date/datetime:
binDate()— granularidade auto baseada no range (UTC; >2y → ano, 1mo–2y → mês, <1mo → dia) - multitext:
explodeMultitext()flatten arrays — 1 arquivo conta em N colunas, chi² inválido (stats[i] = null)
- Coluna
(missing)opcional no fim quando há markers em arquivos sem valor preenchido (toggle "Hide missing") - Build matrix
[code × value]; chi² 2×C por código via helper genéricochiSquareFromContingencyextraído deinferential.ts
Helpers puros isolados em analytics/data/binning.ts (testáveis sem registry).
Render (codeMetadataMode.ts): canvas 2D no pattern de docMatrixMode (sem DPR scaling). Linhas = códigos, colunas = valores binados, célula colorida via heatmapColor(value, maxValue, isDark). Display tem 3 modos via radio: Count (default) / % by row (code) / % by column (value) — útil pra "como o código X se distribui" vs "do que o perfil Y fala". Coluna lateral fixa exibe χ²=N · p=N por código, com asterisco quando p<0.05 e — para multitext.
Sort interativo dividido em 2 headers (sem persistência, volátil por sessão):
- Header Code (esquerda): cicla
total desc → total asc → name asc → name desc - Header χ² · p (direita): cicla
χ² desc → χ² asc → p asc → p desc
Tooltip de hover mostra (código × valor, count, % row, % col). Banner condicional quando caseVariableFilter.name === cmVariable ("Filtering by X while using as dimension"). CSV export com colunas code, total, <values…>, (missing)?, chi2, df, p, cramers_v — campos chi² vazios para linhas multitext (parse-friendly em R/Python).
State volátil em AnalyticsView: cmVariable, cmDisplay, cmHideMissing, cmSort. Reset ao reabrir view, mesmo pattern de chiGroupBy/chiSort.
src/
core/
baseSidebarAdapter.ts — base class for all sidebar adapters (listener wrapping, hover state, deleteCode, updateMarkerFields)
markerResolvers.ts — shared marker lookup/resolution utilities across engines
codeVisibility.ts — pure helpers: isCodeVisibleInFile, shouldStoreOverride, cleanOverridesAfterGlobalChange
codeVisibilityPopover.ts — shared popover body (renderCodeVisibilityPopoverBody) + floating wrapper (openCodeVisibilityPopover)
visibilityEventBus.ts — rAF coalescing bus — coalesces visibility notifications in a single animation frame
media/
mediaCodingModel.ts — shared CodingModel for audio/video engines
mediaSidebarAdapter.ts — shared sidebar adapter for audio/video engines
mediaCodingMenu.ts — shared coding menu for audio/video engines
analytics/board/
boardTypes.ts — TypeScript types for Research Board nodes, arrows, connections
fabricExtensions.d.ts — Fabric.js type extensions for custom node properties
obsidian-internals.d.ts — type declarations for undocumented Obsidian internals
Feature em duas camadas (global + per-doc override) com event bus de notificação:
- Estado global:
CodeDefinition.hidden?: boolean— quando true, código fica oculto por padrão em todos os docs - Override per-doc:
QualiaData.visibilityOverrides[fileId][codeId] = boolean— inverte a visibilidade para esse fileId específico - Composição:
isCodeVisibleInFile(codeId, fileId)prioriza override sobre global. Override presente => usa override; ausente =>!globalHidden - Semântica B (self-cleaning): overrides só existem enquanto divergem do global.
shouldStoreOverride(entry-side) +cleanOverridesAfterGlobalChange(global-change sweep) garantem JSON enxuto - Mutations no registry:
setGlobalHidden(codeId, hidden),setDocOverride(fileId, codeId, visible),clearDocOverrides(fileId). Todas emitemvisibility-changedevent (Set distinto doonMutate) - Cleanup automático:
registry.delete(id)remove overrides órfãos — cobre merge transitivamente (executeMergechamaregistry.delete(sourceId)) - Vault sync:
migrateFilePathForOverrides(oldPath, newPath)no rename,clearFilePathForOverrides(fileId)no delete - Event bus (
visibilityEventBus): singleton que coalesce notifications viarequestAnimationFrame(fallbackqueueMicrotaskpara jsdom). Cada engine subscreve por VIEW INSTANCE (não por fileId) — multi-pane tem múltiplos subscribers - Render filter em 6 engines: cada engine filtra
marker.codes.filter(app => registry.isCodeVisibleInFile(app.codeId, fileId)). Se resulta vazio, marker é pulado. CM6 markdown rebuild atômico (decorations); PDF/CSV/Image/Audio/Video refresh pontual (DOM-based, o que é visível muda, o resto não re-renderiza) - Escopo: Analytics e export não afetados — filter só na layer de render. Intencional (design spec)
Módulo src/export/ implementa export nos formatos QDC (codebook) e QDPX (projeto completo) do padrão REFI-QDA v1.5. Módulo src/import/ implementa o caminho inverso.
Export — Arquitetura em camadas:
xmlBuilder.ts— primitivas XML (escaping, atributos, elementos). Zero dependência de DOMcoordConverters.ts— conversão de coordenadas por engine:- Markdown:
lineChToOffset()(CM6 line:ch → Unicode codepoint offset) - PDF shapes:
pdfShapeToRect()(normalized 0-1 → PDF points, bottom-left origin, ellipse/polygon via bounding box) - Image:
imageToPixels()(normalized 0-1 → pixel bounding box) - Media:
mediaToMs()(seconds float → milliseconds integer)
- Markdown:
qdcExporter.ts— codebook XML.buildCodebookXml(registry, namespace?)gera hierarquia por nesting recursivo viagetChildren(). Namespace opcional para embedding em<Project>(herda do pai)qdpxExporter.ts— projeto completo. Source builders por engine (buildTextSourceXml,buildPdfSourceXml,buildImageSourceXml,buildAudioSourceXml,buildVideoSourceXml). Cada builder gera<Source>+<Selection>+<Coding>+<NoteRef>.exportProject()orquestra tudo + ZIP via fflateexportModal.ts— UI pre-export (formato, toggle sources, disclaimer CSV)exportCommands.ts— 2 commands na palette + botão no analytics + factoryopenExportModal()
Padrões chave:
- GUID correlation: Cada source builder armazena
guidMap.set('source:' + filePath, srcGuid). O helperaddSourceFile()lê esse GUID para garantir que o pathinternal://no XML e o entry no ZIP coincidam - Marker memos como Notes: Marker com
memogera<Note>+<NoteRef>no<Selection>. GUID do note:note_{selectionGuid}. Caminho exclusivo de marker memo - Code/Group/Relation memos como
<MemoText>direto (#25, 2026-04-27):CodeDefinition.memo,GroupDefinition.memo,CodeRelation.memosaem como<MemoText>child de<Code>/<Set>/<Link>— não passam pela Notes collection. Quando memo presente, elementos antes self-closing viram open/close (element-form switch embuildCodeElement/buildSetElement+ emission de<Link>noqdpxExporter.ts) - Relations como Links:
buildLinksXml()converteCodeDefinition.relationseCodeApplication.relationsem<Link>comdirection(Associative/OneWay).<MemoText>opcional como child quando relation tem memo - Warnings:
ExportResult.warnings[]acumula problemas (source missing, PDF offsets approximate, image dimensions unreadable). Modal exibe via Notice
Import (src/import/) — Arquitetura em camadas:
- xmlParser.ts: Helpers DOMParser —
parseXml,getChildElements,getAttr,getNumAttr,getTextContent,getAllElements - coordConverters.ts: Conversão inversa —
offsetToLineCh(codepoint→CM6 line:ch com surrogate pairs),pdfRectToNormalized(PDF points bottom-left→0-1),pixelsToNormalized,msToSeconds - qdcImporter.ts:
parseCodebook(recursivo com hierarquia + NoteRef→description +<MemoText>em<Code>),applyCodebook(merge/separate + guidMap QDPX→Qualia +mergeMemosanáloga amergeDescriptionsquando entidade pré-existe com memo) - qdpxImporter.ts:
parseSources(5 tipos),parseNotes(com detecção[Magnitude: X]),parseLinks(com<MemoText>opcional),parseSetsFromXml(regex-based, parsea<MemoText>em<Set>),previewQdpx,importQdpx(ZIP→vault: extrai sources, cria markers por engine, batch de text markers com offset→lineCh, memos standalone como .md,applyLinkscode-level + marker-level preservandorelation.memo) - importModal.ts: File picker, preview com contagem, dropdown conflitos, toggle sources, flows QDC e QDPX separados
- importCommands.ts:
import-qdpx,import-qdcna palette + botão analytics - Magnitude round-trip: Export codifica
CodeApplication.magnitudecomo Note[Magnitude: X]e preserva a definição da escala do código (type+values) em uma Note padrão[Qualia Magnitude Definition: {...}], referenciada pelo<Code>via<NoteRef>. Import reconstrói os dois níveis separadamente. A forma com NoteRef evita um atributo custom proibido pelo XSD REFI 1.0.
PDF text transport (baseline em 2026-04-23; paridade entre vaults fechada em 2026-09-03):
O PDF tem um desafio específico: runtime usa beginIndex/beginOffset/endIndex/endOffset alinhados com .textLayerNode do viewer (que são DOM-specific), mas QDPX espera startPosition/endPosition em codepoints no PlainText consolidado. Solução em 3 módulos novos (export) + 2 (import):
Export pipeline (loadPdfExportData → resolveMarkerOffsets):
src/pdf/pdfExportData.loadPdfExportData(app, filePath)— carrega PDF viawindow.pdfjsLibheadless, extrai dims por página E rodabuildPlainText.ensurePdfJsLoadedabre um PDF em leaf temporária (comtabHeader.display='none'+containerEl.visibility='hidden') quandowindow.pdfjsLibainda não foi populado (typical em vault novo pré-import)src/pdf/pdfPlainText.buildPlainText(doc)— concatenagetTextContent().items.strcom\fentre páginas. Strip whitespace leading/trailing de cada item pra evitar double-spacessrc/pdf/resolveMarkerOffsets(plainText, pageStartOffsets, marker)— tentaindexOfexato domarker.textna página. Fallback: normaliza whitespace em ambos os lados (\s+→) e busca na versão normalizada, mapeando offsets de volta pro plainText original. Sinalizaambiguous: truequando text aparece múltiplas vezes (warning mas exporta primeira ocorrência)
Import pipeline (extractAnchorFromPlainText → placeholder + runtime resolve):
src/pdf/extractAnchorFromPlainText(plainText, pageStartOffsets, startPos, endPos)— retorna{text, page}(page 1-based). Chamado deqdpxImporter.createPdfMarker- Marker criado com
textpreservado + indices placeholder (0,0,0,0) src/pdf/resolvePendingIndices(pageEl, text)— invocado porpageObserver.renderPageno primeiro render do PDF. Faz text-search no DOM.textLayerNode, popula indices, salva silent. Depois render normal pinta highlight
Convenção de página: marker.page é 1-based (vem de data-page-number do viewer). pageStartOffsets é 0-based. Export/import convertem nas bordas.
Convenção de shape coords: PercentShapeCoords (0-100), match do viewBox SVG 0 0 100 100. pdfShapeToRect/pdfRectToNormalized dividem/multiplicam por 100 antes de converter pra PDF points — XML sai dentro da spec REFI-QDA. Renomeado de NormalizedShapeCoords em 2026-04-24 pra não induzir erro (o nome mentia — a convenção sempre foi 0-100).
Why: Caminho runtime (render/capture/drag) permanece index-based e intocado. Anchor em text só vive no lado export/import, estruturando o transporte sem mudar o schema do marker.
Estado real em 2026-09-03: o exporter projeta endpoints PDF diretamente para
offsets Unicode da Representation, deriva bbox da geometria atual e falha de forma
atômica quando um marker ativo não pode ser coberto. Markers multipágina viram
fragmentos visuais correlacionados a uma Selection textual; siblings importados
compatíveis são reagrupados sem colapsar markers nativos coincidentes. Users,
Coding.creatingUser, identidade externa, magnitudes, memos e relações fazem
round-trip Qualia↔Qualia. O checkpoint manual preservou 7 selections lógicas, 9
fragmentos PDF, 8 Codings semânticos e 1 relação. Somente o Marco 7, adiado até
haver conta/instalação, valida abertura, edição e retorno pelo Atlas. Ver
docs/superpowers/specs/2026-09-01-qdpx-multicoder-import-design.md.
Módulos em src/export/tabular/ (8 arquivos) — complementa QDPX. Exporta dados relacionais flat (CSV + README.md) sem schema REFI-QDA, consumível direto em R/tidyverse ou Python/pandas.
Primitivos:
csvWriter.ts— função puratoCsv(rows: CellValue[][]): string. RFC 4180 (escape comma/quote/newline via double-quotes), UTF-8 BOM prepended (Excel detecta encoding correto), sem dependência de DOMreadmeBuilder.ts— geraREADME.mdembutido no zip com schema detalhado de cada CSV + snippets R/tidyverse (dplyr joins) e Python (pandas merge) + seção Warnings (condicional: "Orphan codeId" se aplicável)
Builders por tabela:
buildSegmentsTable.ts— mais complexo: consolida 6MarkerTypes persistidos em 8sourceTypes (markdown, pdf_text, pdf_shape, image, audio, video, csv_segment, csv_row). Colunaengine(coarse) +sourceType(fine). Shape coords em JSON quando toggle on. Media timestamps from/to em milissegundos (ISO 8601 string, zero offsetZ). Fallback text pra deleted files (arquivo movido/deletado → warning + text='' mas segment sai com outros fields preenchidos)buildCodeApplicationsTable.ts— 1 linha per (segment, code) de todos engines. OrphancodeId→ skip + warning (segment mantém outros codes válidos). Colunas: segment_id, code_id, magnitude (ou NULL), relations_jsonbuildCodesTable.ts— codebook denormalizado 1 linha per code. Pastas (organização visual) não aparecem (sem significado analítico em CSV). Colunas:id, name, color, parent_id, description, memo, magnitude_config, groups.magnitude_configserializada como JSON,groupscomo;-separated namesbuildGroupsTable.ts— codebook de groups standalone. Colunas:id, name, color, description, memobuildCaseVariablesTable.ts— long format, 1 linha per (fileId, variable). Lê direto dedm.section('caseVariables')(evita dependência emCaseVariablesRegistry). Multitext → JSON array. NULL → empty cell (row mantido)buildRelationsTable.ts— unifica code-level + application-level via colunascope(code|application). Colunas separadasorigin_code_id/origin_segment_id(não composite key — facilita left-join no R). Target sempretarget_code_id(relations sempre code-to-code). Colunamemono fim — code-level populada quando há memo, application-level vazia até UI lander (schema-ready)
Orchestrator:
tabularExporter.ts— funçãoexportTabular(app, dm, registry, opts). Resolve textos de CSV da vault (markers CSV de PDF/markdown/image/etc armazenam indices, não text — exportador lê arquivo viavault.getAbstractFileByPath+instanceof TFile+vault.read(file), tolerando parse errors parciais). Roda 5 builders em série, concatena warnings, gera README, zipa viafflate.zipSynccomtoU8wrapper realm-safety (mesmo pattern deqdpxExporter.ts)
Runtime flow: ExportModal.doExport (com format === 'tabular') → exportTabular(app, dm, registry, opts) com opts: { includeRelations?: boolean, includeShapeCoords?: boolean } → vault.createBinary(fileName, zip). Modal UI: radio tabular no formatSelect + 2 toggles (default on)
Tier 3 do Coding Management. Smart Code = "código virtual" definido por predicate (filtro reutilizável) em vez de application manual marker-a-marker. Pattern equivalente ao "Smart Codes" do Atlas.ti / "Code Sets" parametrizados do MAXQDA.
Schema (PredicateNode AST) em src/core/types.ts:
type PredicateNode = OpNode | LeafNode;
interface OpNode {
op: 'AND' | 'OR' | 'NOT';
children: PredicateNode[];
}
interface LeafNode {
leaf: 'hasCode' | 'caseVarEquals' | 'caseVarRange' | 'magnitudeGte'
| 'magnitudeLte' | 'inFolder' | 'inGroup' | 'engineType'
| 'relationExists' | 'smartCode';
// ... params específicos do leaf
}11 leaves cobrem dimensões ortogonais: estrutura (hasCode, inFolder, inGroup), metadata (caseVarEquals, caseVarRange), magnitude (magnitudeGte/Lte), engine (engineType), relations (relationExists), texto (textContains — substring com opt-in caseSensitive), e auto-referência (smartCode — nesting). Combinados via OpNode (AND/OR/NOT). textContains aciona invalidação file-level via SmartCodeCache.invalidateForFileText no vault.on('modify') (rede de segurança pra mudança de texto que não passe por MarkerMutationEvent — edição externa, futuras engines).
SmartCodeDefinition no registry:
{ id: 'sc_*', name, color, predicate: PredicateNode, memo?: MemoRecord, paletteIndex, createdAt }Pipeline (módulos puros isolados em src/core/smartCodes/):
| Módulo | Responsabilidade |
|---|---|
evaluator.ts |
evaluate(predicate, marker, ctx) — runtime hot path, short-circuit AND/OR, cycle-safe via ctx.visiting: Set<scId> |
validator.ts |
validate(predicate, registry) — pré-save, detecta cycles + name collision + broken refs + magnitude type mismatch |
dependencyExtractor.ts |
Extrai {codeIds, caseVarKeys, folderIds, groupIds, smartCodeIds, engineTypes} do predicate (índices reversos pra cache) |
serializer.ts |
JSON ↔ AST + normalizer (canoniza ordem de children pra diff estável) |
builderTreeOps.ts |
add/remove/move/changeOp/replaceLeaf puros do builder UI (modal row-based) |
cache.ts |
SmartCodeCache singleton — markerByRef + invalidação granular + chunked compute (100 markers/chunk pra cache miss grande) |
matcher.ts |
computePreview(predicate, allMarkers) — preview live <300ms no builder |
smartCodeRegistry.ts |
Classe stateful com addOnMutate(fn) (mesmo pattern de CodeDefinitionRegistry), CRUD + autoRewriteOnMerge (re-aponta predicates após code merge) + diffPredicateLeaves (audit log) |
Integração cross-engine via MarkerMutationEvent (canal paralelo a onChange — ver TECHNICAL-PATTERNS.md §37): cada mutação de marker emite {engine, fileId, markerId, prevCodeIds, nextCodeIds, codeIds, marker}. Cache applyMarkerMutation(event) atualiza markerByRef incremental + invalida só SCs cujo predicate referencia algum codeId em event.codeIds. Vault de 10k markers + 100 SCs: edit de 1 marker re-computa 1-3 SCs típico.
Audit log entity discriminator (AuditEntry.entity?: 'code' | 'smartCode'): 5 event types sc_* (sc_created, sc_renamed, sc_predicate_edited, sc_text_edited, sc_deleted). Coalescing 60s pra text edits + Set union pra predicate edits (múltiplas iterações de builder viram 1 entry). ⚡ icon na Codebook Timeline distingue eventos SC.
Integração em Analytics (helper getSmartCodeViews em smartCodeAnalytics.ts): SC entries aparecem alongside códigos regulares em 6 modes (frequency, cooccurrence, evolution+temporal, codeMetadata, lagSequential+polar, memoView). Filter UI tem chips ⚡ no topo da codes section. SC entries no Frequency mode aceitam drag + Add to Board (paridade com codes).
Integração no Code Explorer: grupo "⚡ Smart Codes" top-level no tree com estrutura SC → file → matches. Click em match navega cross-engine via navigateToMarker. Subscribe a cache + registry mutations + model.onChange (workaround pra eventos que SC3 não cobre, raros).
QDPX export/import: bloco <qualia:SmartCodes> em namespace custom xmlns:qualia="urn:qualia-coding:extensions:1.0". Import 2-pass: (1) alocar IDs novos pra todos os SCs, (2) resolver refs (incl. nesting smartCode leaves apontando pra outros SCs). Round-trip preservado.
CSV tabular: smart_codes.csv com coluna predicate_json. README do zip ganhou snippets R/Python pra reconstruir SCs em external analysis.
Comando palette: Smart Codes: Open hub (lista) + Smart Codes: New (builder direto). Smart Code Detail wirado inline na sidebar (Code Detail, modo "All Codes" — section ⚡ acima dos códigos regulares, click abre detail no mesmo painel).
Sistema de propriedades tipadas por arquivo (mixed-methods: cruzar códigos × metadata demográfica). Funciona para todos os 7 formatos — md, pdf, image, audio, video.
src/core/caseVariables/caseVariablesRegistry.ts — instância única criada no main.ts, inicializada/descarregada via this.cleanups. API async de leitura/escrita por fileId. Emite addOnMutate callbacks em toda mutação; o main.ts usa esse hook para invalidar o consolidationCache global.
| Formato | Source of truth | Mirror em data.json |
|---|---|---|
| Markdown | Frontmatter (fileManager.processFrontMatter) |
Sim — sincronizado por metadataCache.on('changed') |
| Binários (PDF, Image, Audio, Video) | data.json.caseVariables.values[fileId] |
É o primário (não há frontmatter possível) |
Sync reativo (Markdown): metadataCache.on('changed') dispara após qualquer escrita em frontmatter. O registry escuta esse evento e atualiza o mirror em memória. Quando a escrita foi feita pelo próprio plugin, writingInProgress: Set<fileId> impede a re-notificação (reentrancy guard — detalhe em TECHNICAL-PATTERNS.md §15).
metadataTypeManagerdo Obsidian (API interna viaobsidianInternalsApi.ts) — resolve tipos de propriedades definidos pelo usuário no Obsidian- Mapa próprio do plugin (
data.json.caseVariables.types) 'text'como fallback
inferPropertyType.ts infere tipo via regex (number / date / datetime / checkbox / text) para valores sem tipo declarado.
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| PropertiesEditor | propertiesEditor.ts |
Componente DOM base: render de rows, inline edit, add row, confirm remove |
| PropertiesPopover | propertiesPopover.ts |
Wrapper popover injetado via view.addAction em todo FileView |
| CaseVariablesView | caseVariablesView.ts |
ItemView (painel lateral) — registrado com CASE_VARIABLES_VIEW_TYPE + comando open-case-variables-panel |
- Lifecycle de arquivos:
registerFileRename+vault.on('delete')propagam rename/delete para o registry. Botão de ação injetado em todoFileViewvia triplo listener emmain.ts:active-leaf-change(user navega entre leaves),layout-change(splits e reconfigurações de painéis) efile-open(cobre race onde layout-change vêview.file===null). Dedupe porWeakMap<View, listener>; no early-return o listener armazenado é re-invocado pra refresh de badge (cobre caso same-view-new-file). - Analytics filter:
caseVariableFilteremFilterConfig— aplicado no nível daAnalyticsViewantes de qualquer mode module. Não toca nos 6 stats engines. - Cache invalidation:
caseVariablesRegistry.addOnMutate(() => consolidationCache.invalidateAll())— mudança em qualquer variável invalida o cache analítico global. - QDPX export/import:
src/export/caseVariablesXml.tsgera<Variable>dentro de cada<Source>+ seção<Cases>com<SourceRef>. Round-trip preserva tipos (number permanece number, boolean permanece boolean).
QualiaData ganhou campo caseVariables: CaseVariablesSection ({ values: Record<fileId, Record<string, VariableValue>>; types: Record<string, PropertyType> }). Default {values:{}, types:{}} em createDefaultData() e clearAllSections().
src/core/caseVariables/
caseVariablesTypes.ts — PropertyType, VariableValue, CaseVariablesSection, OBSIDIAN_RESERVED
obsidianInternalsApi.ts — encapsula metadataTypeManager do Obsidian (API interna)
caseVariablesRegistry.ts — classe central (CRUD, initialize/unload, sync, events)
typeIcons.ts — mapping PropertyType → Lucide icon
inferPropertyType.ts — regex-based type inference (number/date/datetime/checkbox/text)
propertiesEditor.ts — componente DOM (render + inline edit + add row + confirm remove)
propertiesPopover.ts — wrapper popover via view.addAction
caseVariablesView.ts — painel lateral (ItemView)
caseVariablesViewTypes.ts — constante CASE_VARIABLES_VIEW_TYPE
src/export/
caseVariablesXml.ts — QDPX helpers (renderVariableXml, variableTypeToQdpx, renderVariablesForFile, renderCasesXml)
Canvas Fabric.js para síntese de findings:
- Sticky notes — cor selecionável, texto livre
- Chart snapshots — captura de qualquer visualização analítica
- Text excerpts — trechos de qualquer marker
- Code cards — estatísticas de um código (frequência, co-ocorrência)
- KPI cards — métricas customizadas
- Cluster frames — agrupamentos visuais (Group com Rect+Textbox, sendToBack)
- Arrows como Line + Triangle separados (NÃO Group) — linkados por boardId
- Connections bidirecionais entre nós
- Phase 1 — Canvas fundation: Fabric.js canvas, pan/zoom, grid snap, serialization to
board.json. - Phase 2 — Sticky notes: Color picker, inline text editing (double-click to edit via
IText), resize handles. - Phase 3 — Data-linked nodes: Excerpt nodes from markers, Code cards with live stats, KPI cards with custom formulas.
- Phase 4 — Connections: Arrow drawing between nodes (Line + Triangle, NOT Group), connection persistence by
boardId. - Phase 5 — Chart snapshots: Capture any analytics visualization as PNG
dataUrl, embed in canvas. - Phase 6 — Cluster frames: Group semantics via
Rect+Textbox(sendToBack), grid layout 2 columns, drag children in/out.
src/analytics/board/
boardCanvas.ts — Fabric.js canvas lifecycle, pan/zoom, grid snap
boardNodes.ts — Node factory: createSticky, createExcerpt, createCodeCard, etc.
boardNodeHelpers.ts — Shared node helpers (cardBg, textbox, badges, theme)
boardArrows.ts — Arrow creation (Line + Triangle), connection tracking by boardId
boardToolbar.ts — Toolbar UI: add node buttons, zoom controls, export
boardData.ts — Serialization/deserialization of board state
boardDrawing.ts — Freehand drawing mode
boardClusters.ts — Code card clustering by co-occurrence
boardTypes.ts — Discriminated union types for board nodes
fabricExtensions.d.ts — Ambient types for Fabric.js custom properties
src/analytics/views/
boardView.ts — ItemView lifecycle, canvas events, drag & drop
boardPersistence.ts — board.json read/write/clear via DataAdapter
boardContextMenu.ts — Right-click context menu for board nodes
| Node Type | Key Behavior |
|---|---|
| Sticky | Inline editing via double-click (IText), color selecionável (8 preset colors), resize handles |
| Snapshot | PNG dataUrl embedded, captured from any analytics chart, read-only display |
| Excerpt | Source badge (file + line range), code chips with swatches, text content truncated with ellipsis |
| Code Card | Code swatch + frequency count + source badges (which files), click navigates to Code Detail |
| KPI Card | Big numeric value + label + accent color bar, custom formula or auto-computed |
| Cluster Frame | Grid layout 2 columns, Rect + Textbox Group with sendToBack, children draggable in/out |
board.json— arquivo separado dodata.json- Path migrado de formato legacy (Decisão D19 do merge)
- 500+ markers/arquivo → considerar interval tree em vez de linear scan no
getMarkersInRange() - 1000+ decorations/viewport → degradação de scroll no CM6
- Sidebar refresh → debounce 300ms+ para evitar re-render a cada keystroke
- PDF 50+ páginas → lazy rendering via
textlayerrenderedevent por página
- Output:
main.js(~2.17 MB bundled) - Lazy imports pontuais:
svd-js,Chart.js(viaawait import()sob demanda)
Contexto: A Camada 12 do merge plan propunha multi-build esbuild para reduzir o bundle (~2.17 MB → ~210 KB + engines sob demanda). Esse assunto foi levantado e revisitado múltiplas vezes durante o desenvolvimento. A pesquisa abaixo encerra a discussão.
1. A community store só distribui 3 arquivos
Quando um usuário instala um plugin pela store, Obsidian baixa exatamente: main.js, manifest.json, styles.css. Nenhum outro arquivo é baixado. Não existe mecanismo para distribuir chunks adicionais (.js, .zip, assets). Já houve feature request no forum para suporte a assets — nunca implementado.
2. Obsidian carrega plugins via eval(), não via module system
Plugins são carregados via eval() do JavaScript — não por <script> tags, não por import(), não por require(). Consequências:
__dirnameretorna o path do app Electron (.asar), não o diretório do plugin- Não existe cadeia de resolução de módulos para arquivos adicionais
require('./chunk.js')procuraria no lugar errado
Fonte: How to debug Obsidian plugins
3. esbuild splitting: true exige ESM — Obsidian exige CJS
Da documentação oficial do esbuild:
"Code splitting currently only works with the
esmoutput format."
O issue #1341 pedindo suporte a CJS splitting foi fechado sem perspectiva de implementação. Obsidian plugins precisam de format: "cjs" porque o loader via eval() espera CommonJS. Incompatibilidade fundamental.
4. require() manual não resolve distribuição
Em teoria, require() funciona no Electron desktop para carregar .js do disco. Mas:
__dirnameaponta pro lugar errado (precisa hackear path viaapp.vault.adapter.getBasePath())- A store não distribui os chunks — o usuário nunca receberia os arquivos
- No mobile,
require()para arquivos arbitrários é bloqueado
5. import() dinâmico funciona... para URLs externas
await import('https://cdn.jsdelivr.net/npm/...') funciona no Electron. Mas requer internet, carrega de CDN (não local), e não passaria na review da community store (dependência de rede para funcionalidade core).
Fonte: Using third party libraries by dynamic imports (forum)
Todos os plugins relevantes shippam um único main.js monolítico:
| Plugin | main.js | Code splitting? |
|---|---|---|
| Excalidraw | 8.2 MB | Não |
| Dataview | 2.4 MB | Não |
| Qualia Coding | 2.1 MB | Não |
| Kanban | ~1 MB | Não |
Quando um usuário pediu ao autor do Excalidraw para dividir o bundle de 8.2 MB (issue #2349), a resposta foi:
"You need to raise this with the Obsidian devs. I can't do anything about this."
O issue foi fechado. O autor do Excalidraw — um dos devs mais experientes do ecossistema — confirmou que é limitação da plataforma, não do plugin.
minify: trueem produção (já ativo)treeShaking: true(já ativo)await import()pontual parasvd-jseChart.js(lazy load de libs pesadas sem refatorar o build)- Aceitar que 2.1 MB é normal (Excalidraw é 4× maior)
Code splitting em plugins Obsidian é uma limitação da plataforma: distribuição (3 arquivos), loader (eval()), formato (CJS obrigatório), e mobile (sem require()). Todo plugin grande do ecossistema aceita o bundle monolítico. Não é otimização prematura — é impossibilidade técnica para plugins distribuídos pela community store.
Reavaliável apenas se Obsidian mudar seu sistema de carregamento de plugins para suportar ESM ou distribuição de múltiplos arquivos.
| Risk | Impact | Mitigation |
|---|---|---|
| data.json migration | Data loss if schema changes without migration path | Always provide migration functions; never drop fields; merge defaults on load |
| UX with 20+ codes ("color soup") | Visual clutter makes highlights unreadable | Toggle code visibility in sidebar; opacity blending for overlapping decorations |
| Empty Code Explorer (onboarding) | New users see empty sidebar, unclear next step | Placeholder message with "Create your first code" CTA + sample workflow |
| Plugin conflicts (Highlighter, Comments, PDF++) | CSS collisions, event interception, DOM mutation | Strict CSS namespacing (codemarker- prefix); feature detection over version checks; no monkey-patching |
| Mobile sidebar behavior | Sidebar collapses differently on mobile Obsidian | Desktop-only target (v1.5.0+); mobile support deferred |
| vault.adapter vs loadData concurrency | loadData() só no bootstrap, depois tudo em memória. Sync externo (Dropbox, iCloud, Git) pode sobrescrever data.json em runtime → lost update (não só stale read). Limitação da plataforma: toda a API loadData/saveData do Obsidian funciona assim. |
Single DataManager instance; debounced saves; flushPendingSave() on unload. Não reconcilia mudanças externas em runtime. Reavaliável com vault.on('raw') se necessário. |
| Leaf view DOM without framework | Verbose imperative UI code; hard to maintain | Base classes (BaseCodeExplorerView, BaseCodeDetailView) with abstract methods; eventual extraction to shared components |
| Analytics concentration (Codex) | 62 arquivos, ~11.800 LOC — maior fatia do sistema, ponto provável de regressão e lentidão | Split em 19 mode modules (feito); monitorar crescimento; lazy imports para Chart.js/svd-js |
| data.json com vaults grandes (Codex) | Persistência monolítica pode virar gargalo com centenas de markers densos | JSON suficiente para volume QDA típico; caminho de migração mantido aberto (§3.4) |
| Registry rename collision (Codex) | update() sem guard |
FEITO — guard rejeita rename se nome existe (+4 testes) |
| Clear All Markers lifecycle (Codex) | FEITO — evento qualia:clear-all + clearAll() nos models + clearBoard() |
|
| FileInterceptor destrói multi-pane (Codex) | leaf.detach() + singleton leaf por engine — quebra workflow nativo |
Bug de UX. Ref: mirror-notes viewId pattern. |
| CI coverage (Codex) | FEITO — vitest run --coverage no CI, thresholds 30/25/30/30 |
|
| View readiness (Codex) | Race conditions em Board e Image por falta de contrato de readiness | FEITO — Two-phase: polling descobre a view (max 500ms), waitUntilReady() promise garante que canvas/dados estão prontos. Error paths resolvem via try/finally (Board) e catch (Image). Load race em Image prevenido por generation counter. |
- Obsidian v1.7.7:
OldTextLayerBuildercomtextDivs/textContentItems - Obsidian v1.8.0+:
TextLayerBuildercom.textLayernested getTextLayerInfo()usa feature detection (não version check)
- CSS prefixado
codemarker-(nuncapdf-plus-) - Highlight layer separada
- Sem monkey-patching de internals do PDF.js
- Sem interceptação de eventos conflitante
- Target: v1.5.0+ (desktop only)
(item as any).dompara acessar DOM de componentes nativos — hack que pode quebrar
Como representar N códigos no mesmo trecho de texto? Quatro opções foram avaliadas:
Cada código aplica sua própria Decoration.mark com cor de background. Quando N códigos se sobrepõem, N decorações empilham com opacity blending.
Pros: Intuitivo (cor = código), padrão em QDA tools, visual rico. Cons: "Color soup" com >5 códigos sobrepostos; cores misturadas perdem identidade; daltônicos impactados.
Barras verticais coloridas na margem esquerda (estilo MAXQDA/NVivo). Cada código ocupa uma coluna.
Pros: Escala para N códigos sem poluir o texto; hierarquia visual clara; labels possíveis. Cons: Ocupa espaço horizontal; complexidade de layout (collision avoidance, dynamic columns); requer panel extension separada.
Pequenos chips/dots inline no início ou fim do trecho codificado.
Pros: Mínimo footprint visual; não altera leitura do texto. Cons: Difícil localizar visualmente; não mostra extensão do trecho; sem affordance para interação.
Nenhuma decoração visível permanente. Hover sobre texto revela tooltip com códigos atribuídos.
Pros: Zero poluição visual; pragmático como ponto de partida; implementação simples (~100 LOC). Cons: Sem indicação visual de que texto está codificado; depende de hover (sem mobile); descobrabilidade zero.
| Combination | Visual Clarity | Scalability (20+ codes) | Implementation Cost | Mobile-Friendly |
|---|---|---|---|---|
| A alone | ★★★★ | ★★ | Medium | ✅ |
| B alone | ★★★★★ | ★★★★★ | High | ✅ |
| A + B | ★★★★★ | ★★★★ | High | ✅ |
| A + D | ★★★★ | ★★★ | Medium | ❌ |
| B + D | ★★★★★ | ★★★★★ | High | ❌ |
| D alone | ★★ | ★★★★★ | Low | ❌ |
Decisão: D alone is the most pragmatic starting point. Minimal implementation cost, allows iterating on the interaction model before committing to visual decoration complexity. A + B is the long-term target (implemented as margin bars + per-code decorations with opacity blending).
interface Workspace {
activeProject: string | null; // null = global
codes: CodeDefinition[];
segments: Segment[];
projects: QDAProject[];
settings: { /* per-workspace settings */ };
}
interface Code extends CodeDefinition {
scope: 'global' | string; // global or projectId
parentId?: string;
memo?: string;
weight?: number;
}
interface Segment {
id: string;
fileId: string;
from: { line: number; ch: number };
to: { line: number; ch: number };
codeIds: string[];
memo?: string;
weight?: number;
created: number;
}
interface QDAProject {
name: string;
created: string;
documents: string[];
codebook: { codes: Code[]; codeGroups: CodeGroup[] };
segments: Segment[];
memos: Memo[];
documentVariables: { fileId: string; variables: Record<string, any> }[];
savedQueries: SavedQuery[];
}.obsidian/plugins/qualia-coding/
data.json — global workspace (codes, segments, per-engine sections)
board.json — Research Board canvas state
projects/
<projectId>/
project.json — QDAProject metadata + codebook + segments
board.json — per-project Research Board (optional)
- Global codes live in
data.jsonunderregistry.definitions. Markers reference them by stable ID (codes: CodeApplication[]ondeCodeApplication = { codeId: string; magnitude?: string; relations?: Array<{ label: string; target: string; directed: boolean }> }). - Why IDs: Renomear um código é operação atômica no registry — sem necessidade de propagar para markers. Eliminamos
renameCode()de todos os models e adapters. Nomes são resolvidos viaregistry.getById(codeId)apenas para display. - Helpers centralizados em
codeApplicationHelpers.ts:hasCode(codes, codeId),getCodeIds(codes),addCodeApplication(codes, codeId),removeCodeApplication(codes, codeId). - Delete cascades:
deleteCode(codeId)removes the code from all markers and deletes the definition. Markers left with no codes are also removed. - Legacy migration:
loadMarkers()no markdown convertestring[]→CodeApplication[]automaticamente.extractCodes()no analytics aceita ambos os formatos.
┌─────────────────────────────────────────────────────┐
│ [Project: Global ▼] │
├──────────┬──────────────────────────┬───────────────┤
│ Codebook │ Segments Matrix Docs │ │
│ ├ Emotion│ ┌─────────────────────┐ │ │
│ │ ├ Joy │ │ Selected segments │ │ │
│ │ ├ Sad │ │ with context... │ │ │
│ │ └ Fear │ └─────────────────────┘ │ │
│ ├ Action │ │ │
│ └ Theme │ │ │
├──────────┴──────────────────────────┴───────────────┤
│ Memo: [current code memo] │ Props │ Quick Stats │
└─────────────────────────────────────────────────────┘
- Top bar — Project selector dropdown. Switches
activeProjectin workspace. "Global" = all data. - Left panel (Codebook) — Hierarchical tree of codes. Drag-and-drop reorder. Right-click context menu for rename/merge/delete. Corresponds to
UnifiedCodeExplorerView. - Center panel (Tabs) — Tab switcher between Segments (text retrieval), Matrix (co-occurrence), Docs (document list with variables). Each tab is a sub-view within the leaf.
- Bottom bar — Contextual: shows memo editor for selected code, property inspector for selected segment, and quick statistics (frequency, density).
- Built as an
ItemViewregistered viaregisterView(). - DOM constructed imperatively (no framework) — follows pattern from
BaseCodeExplorerView/BaseCodeDetailView. - Tab switching uses
display: nonetoggling (not destroy/recreate) to preserve scroll state. - Project dropdown triggers full refresh of codebook tree and segment list.
| Metric | Before (6 plugins) | After (unified) | Reduction |
|---|---|---|---|
| Total LOC | ~10,487 | ~4,170 | ~60% |
| Component | Before (instances × engines) | After | Savings |
|---|---|---|---|
| CodeDefinitionRegistry | 6 copies (1 per engine) | 1 (core/codeDefinitionRegistry.ts) |
5 removed |
| CodeFormModal | 5 copies | 1 (core/codeFormModal.ts) |
4 removed |
| SharedRegistry bridge | 6 adapter files | 0 (direct import) | 6 removed |
| Context menus | 5 duplicated menus | 1 (core/codingPopover.ts) |
4 removed |
| Sidebar views | 12 files (Explorer + Detail × 6) | 2 (unifiedExplorerView.ts + unifiedDetailView.ts) |
10 removed |
| Settings tab | 7 separate tabs | 1 (core/settingTab.ts) |
6 removed |
| Method | MD | CSV | Image | Audio | Video | |
|---|---|---|---|---|---|---|
getMarkers() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
addCode() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
removeCode() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
removeMarker() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
getMemo() |
✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
setMemo() |
✅ | ✅ | ❌ | ❌ | ✅ | ✅ |
deleteCode(codeId) |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
save() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
getActiveCodes() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
setHoverState() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
navigateToMarker() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
getMarkerLabel() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
findExistingMarker() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
findOrCreateMarker() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
getMarkersForFile() |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
BaseCodeExplorerView (4 abstract methods):
getTreeItems(): TreeItem[]— Return hierarchical code → file → segment treeonCodeClick(code: CodeDefinition): void— Handle code selection (open detail view)onSegmentClick(segment: BaseMarker): void— Handle segment navigationgetContextMenuItems(node: TreeNode): MenuItem[]— Return context menu entries for tree node
BaseCodeDetailView (4 abstract methods):
renderSegmentPreview(marker: BaseMarker): HTMLElement— Engine-specific segment previewgetNavigationAction(marker: BaseMarker): () => void— Return navigation callbackgetMarkerLabel(marker: BaseMarker): string— Human-readable marker descriptiongetSortedMarkers(codeId: string): BaseMarker[]— Return markers sorted by engine-specific ordering
BaseSidebarAdapter — shared base class for all sidebar adapters (PDF, CSV, image, audio, video). Handles listener wrapping, hover state, deleteCode(), and updateMarkerFields() (with notifyAfterFieldUpdate() hook). PDF overrides both for dual text/shape handling. CSV overrides the notification hook for notifyAndSave(). Markdown implements SidebarModelInterface directly on its model.
| # | Bug | Severity | Engine | Status |
|---|---|---|---|---|
| 1 | Duplicate tab registration on file re-open — registerView called multiple times |
High | All | Fixed (deduplicate in main.ts) |
| 2 | Stale hover state after code rename — hoveredCodeName referenced old name |
Medium | Markdown | Fixed (clear hover on rename) |
| 3 | Phantom markers in CSV segment editor — addMarkerDirect() not cleaned on cell close |
High | CSV | Fixed (cleanup in onCellEditorClose) |
| 4 | Missing updatedAt field in audio/video markers — analytics time-series broken |
Medium | Audio/Video | Fixed (added updatedAt to MediaMarker) |
| 5 | Race condition in sidebar refresh — notify() triggered before save() completed |
Low | All | Fixed (await save before notify) |
Análise independente feita pelo Codex sobre o estado do projeto.
O projeto é um plugin de análise qualitativa para Obsidian com escopo incomum para um plugin desktop: 6 engines de anotação por formato, sidebar unificada e uma camada forte de analytics. A arquitetura central está bem pensada: o bootstrap em src/main.ts é simples, registra engines independentes e conecta tudo por um registro compartilhado (CodeDefinitionRegistry) e um adaptador unificado (UnifiedModelAdapter). A persistência via DataManager é direta e previsível, o que reduz complexidade operacional.
Em termos de porte, a base já é relevante: 163 arquivos TypeScript em src/, com maior concentração em analytics (62 arquivos, ~11.800 linhas). O bundle compilado main.js está em torno de 2.1 MB.
- Modelo unificado como melhor decisão:
CodeDefinitionRegistrycentraliza identidade e cor dos códigos, eUnifiedModelAdapterconsolida operações sem forçar um engine "saber demais" sobre o outro — boa base para evoluir sem reescrever. - Maturidade de engenharia acima da média para plugin Obsidian: testes unitários (Vitest), testes E2E/visuais (wdio), suíte completa passando (39 suites, 1269+ testes). Documentação de produto, arquitetura e roadmap consistente com o código — reduz risco de conhecimento tácito.
- Concentração de complexidade em analytics — maior fatia do sistema, ponto mais provável de regressão, lentidão e dificuldade de manutenção.
- Bundle monolítico — qualquer crescimento futuro em gráficos, board ou mídia impacta tempo de carga e depuração. (Mitigação documentada na §3.10: limitação da plataforma Obsidian, não do plugin.)
- Persistência única em
data.json— simples e bom para velocidade de desenvolvimento, mas pode virar gargalo com vaults grandes, histórico denso de marcações ou analytics pesadas. Não é erro agora; é limite arquitetural previsível. (Mitigação documentada na §3.4: JSON suficiente para volume QDA, caminho de migração mantido aberto.)
Na segunda passagem, lendo ARCHITECTURE.md e BACKLOG.md antes de analisar código, o Codex encontrou 4 itens não mapeados:
- Bug: rename collision no registry —
update()emcodeDefinitionRegistry.ts:80renomeia sem verificar se o nome destino já existe. Resultado: duas definitions com mesmo nome,nameIndexinconsistente, códigos fantasma. Confirmado no código — sem teste cobrindo. - Gap: Clear All Markers não limpa Board —
clearAllSections()zeradata.jsonmasboard.jsonpersiste. Snapshots e code cards ficam apontando para dados inexistentes. Modal promete wipe global mas não entrega. - Trade-off questionável: fileInterceptor
leaf.detach()— destrói a leaf quando arquivo já está aberto em outra view do target type. Impede workflow multi-pane (comparar mesmo artefato em painéis lado a lado). - CI abaixo da narrativa —
npm testnão roda coverage (thresholds são decorativos), CI e2e executa só smoke spec.
Também observou que a sidebar está superdocumentada para capacidade não materializada — drag-and-drop reorder, merge, export, hierarquia ainda não existem no código, embora a decisão de sidebar esteja justificada como investimento futuro.
Implementado em duas camadas:
ConsolidationCache(analytics/data/consolidationCache.ts) — cache por engine no pipeline analytics. Dirty flags por engine (Set<EngineType>) +registryDirty. Só reprocessa engines sujos. Cache hit: ~0.002ms.UnifiedModelAdaptercache (core/unifiedModelAdapter.ts) — dirty flag global + indicesMap<fileId>eMap<id>nas sidebar views.getMarkersForFilede O(n×m) para O(1). Views Explorer/Detail com debouncerequestAnimationFrame.
O gargalo remanescente é CSV/Parquet em memória (rowDataCache), que continua lido inteiro. Atacar se aparecer como problema real em vaults pesados.
Na terceira passagem, o Codex focou em transições entre views vivas e comandos globais:
- Board addToBoard race —
waitForBoardView()considerava view pronta ao encontrar instanceof, mascanvasStateainda era null duranteonOpen(). Fix:waitUntilReady()promise. - Clear All não sincronizava AnalyticsView — view mostrava dados apagados até reopen. Fix: escuta
qualia:clear-all. - Image navigation timeout 200ms — falha silenciosa em máquinas lentas. Fix:
waitUntilReady()promise substitui setTimeout. - migrateFilePath não migrava fileStates — zoom/pan perdido em Image, zoom/lastPosition perdido em Media. Fix: migra
settings.fileStatesno rename. - Color picker cancel suspende refresh —
resumeRefresh()só nochangeevent. Fix: listener emblurcomo fallback. - Lixo estrutural — buckets vazios em Markdown, file containers vazios em Media. Fix: cleanup no
removeMarker().
Diagnóstico do Codex: "A arquitetura está sólida em repouso; o que vaza são transições." Recomendou transformar lifecycle em infraestrutura compartilhada: view ready promise, evento global de invalidation, cleanup de containers vazios.
Padrões adotados: qualia:clear-all event (3 views escutam) e waitUntilReady() promise (Board + Image). Ambos reutilizáveis para futuras views/operações.
O projeto está em um estágio sólido, com arquitetura coerente, boa separação entre núcleo e engines, e disciplina de testes real. O core (registry, adapters, DataManager, models) está acima da média para plugins Obsidian. Os pontos frágeis são fluxos imperativos de lifecycle: comandos globais vs views abertas, readiness assíncrona, rename com state auxiliar, e listeners em caminhos alternativos (cancel, blur, close). Após 3 rodadas e 20 fixes, a superfície de bugs convergiu significativamente.
O CodeDefinition evoluiu de um registro flat para suportar hierarquia, pastas virtuais, magnitude e relações. Todas as fases foram implementadas sem breaking changes — campos opcionais com defaults seguros.
Campos: parentId?: string, childrenOrder: string[], mergedFrom?: string[]
Registry: rootOrder: string[] controla ordem visual dos root codes
Métodos de consulta: getRootCodes(), getChildren(), getAncestors(), getDescendants(), getDepth().
Mutação: setParent(id, parentId, insertBefore?) com detecção de ciclo.
Delete de pai: filhos promovidos a root.
Codebook Panel (evolução do Detail View):
- Navegação stack-based (3 níveis: Codebook → Código → Segmento)
codebookTreeRenderer.ts— virtual scrolling (ROW_HEIGHT=30px, BUFFER_ROWS=10)codebookDragDrop.ts— drag-drop estilo file explorer (zonas top/middle/bottom por row)codebookContextMenu.ts— Menu API (Rename, Add child, Move to, Promote, Merge, Color, Delete)mergeModal.ts—executeMerge()(reassigna markers, reparenta filhos, registramergedFrom, deleta sources) +MergeModalUI
Navegação: refatorada de codeName para codeId em todo o detail view. Events (codemarker:label-click, codemarker:code-click) resolvem name→id na borda (main.ts).
Contagem: colapsado = agregado, expandido = direto. buildCountIndex() pré-computa via post-order DFS.
Campo: folder?: string no CodeDefinition (folha). folders: Record<string, FolderDefinition> no registry, com parentId? + subfolderOrder? em cada folder pra suportar aninhamento N-níveis (#23, 2026-04-26). folderOrder: string[] mantém ordem das roots.
Containers organizacionais sem significado analítico — mesmo aninhadas. Ícone de pasta vs chevron de hierarquia. buildFlatTree recursivo via visitFolders produz FlatFolderNode.depth dinâmico; drag-drop full (nest/reorder/promote, cycle silent return); delete cascade com ConfirmModal preview (count subfolders + códigos + warning markers órfãos).
Config: magnitude?: { type: 'nominal' | 'ordinal' | 'continuous'; values: string[] } no CodeDefinition.
Valor: magnitude?: string no CodeApplication.
Picker fechado — valores declarados são os únicos permitidos. Toggle nas settings controla visibilidade no popover.
Dois níveis:
- Código-level:
CodeDefinition.relations: CodeRelation[]— declaração teórica - Segmento-level:
CodeApplication.relations: CodeRelation[]— interpretação ancorada no dado
Shape: { label: string; target: string; directed: boolean; memo? } (mesma interface compartilhada — ver §14.5). Label livre com autocomplete via <datalist>.
Funções puras: relationHelpers.ts (collectAllLabels, buildRelationEdges).
Analytics: relationsEngine.ts → relationsNetworkMode.ts (Network View com nós = códigos, arestas = relações).
Schema aditivo memo?: string em três entidades, com semântica distinta de description?:
| Entidade | description | memo |
|---|---|---|
CodeDefinition |
Definição operacional (consensual, sai no codebook) | Reflexão analítica processual (histórico de pensamento) |
GroupDefinition |
idem | idem |
CodeRelation |
— (não tem) | Reflexão sobre essa relação específica |
BaseMarker |
— | Já existia antes — anotação sobre o segmento |
Identidade de relation pra editar memo: por tupla (label, target) snapshot, mesmo pattern do delete em baseCodingMenu.ts:585. Setter setRelationMemo(codeId, label, target, memo) atualiza primeira match. Limite conhecido (relations duplicadas com mesma tupla → só primeira é atualizada) é o mesmo do delete existente.
Mutação no registry:
- Code: estende
update(id, changes)com'memo'no Pick — caminho único de mutação preservado - Group:
setGroupMemo(id, memo)dedicado, paralelo asetGroupDescription/setGroupColor - Relation:
setRelationMemo(codeId, label, target, memo)por tupla
CodeRelation.memo é compartilhado entre code-level e application-level (mesma interface em types.ts:27). UI 1.0 só edita o code-level (✎ button em existing rows do Code Detail); application-level é schema-ready — round-trip QDPX/CSV preserva memo mesmo sem UI escrever.
Export: qdcExporter.ts (codebook XML) + qdpxExporter.ts (projeto completo: codes + sources + segments + memos + links + magnitude como Notes).
Import: qdcImporter.ts (codebook com hierarquia + NoteRef→description + parser <MemoText> em Code) + qdpxImporter.ts (5 source types, segments, memos standalone, magnitude, relations via Links com <MemoText> opcional, Sets via parseSetsFromXml regex-based).
Helpers: xmlBuilder.ts (XML generation), coordConverters.ts export (lineChToOffset, pdfShapeToRect, imageToPixels, mediaToMs), xmlParser.ts + coordConverters.ts import (offsetToLineCh, pdfRectToNormalized, pixelsToNormalized, msToSeconds).
UI: exportModal.ts (pre-export config), importModal.ts (conflict resolution).
Dois caminhos paralelos pra memo no QDPX (não confundir):
- Marker memo (existente, preservado intocado):
BaseMarker.memo→<Note>na collection<Notes>+<NoteRef>no<Selection>. GUIDnote_{selectionGuid}. Pipeline<NoteRef>emqdpxImporter.ts:184+segue intocado - Code/Group/Relation memo (#25):
<MemoText>direto como child de<Code>/<Set>/<Link>no codebook XML — não passa pela Notes collection. Element-form switch: quando memo presente, elementos antes self-closing viram open/close (buildCodeElement/buildSetElementemqdcExporter.ts, emission de<Link>emqdpxExporter.ts)
Conflito de import (entidade já existe + memo importado): mergeMemos análoga a mergeDescriptions (existing\n\n--- Imported memo ---\nimported).
Mode memo-view no Analytics que agrega memos das 4 entidades em uma view de leitura analítica unificada com edição inline. Consumer direto da feature #25. Blueprint: codeMetadataMode — pattern declarativo de mode + função pura de agregação + módulos render separados.
Módulos:
analytics/data/memoView.ts—aggregateMemos(allData, registry, filters, caseVariablesRegistry?)pura. LêAllEngineDataraw (não consolidado, pra preservarBaseMarker.memo). RetornaMemoViewResultcombyCode: CodeMemoSection[]oubyFile: FileMemoSection[]+coverage: CoverageStats.analytics/views/modes/memoView/— orchestrator + 8 sub-arquivos (renderCoverageBanner,renderCodeSection,renderFileSection,renderMarkerCard,renderMemoEditor,memoViewOptions,onSaveHandlers,exportMemoCSV,exportMemoMarkdown). Subpasta dedicada porque modes complexos beneficiam de split (vsdashboardMode.tsem arquivo único quando small).
Decisões de design:
- Pivô = código (com hierarquia indentada) + toggle by-file — espelha codebook como narrativa (default). By-file reagrupa por arquivo pra escrever análise por documento.
- Hollow context — pais sem memo mas com filhos com memo aparecem como header fade (preserva contexto de hierarquia).
- Coverage banner —
X/Y codes · X/Y groups · X/Y relations · X/Y markers(totais absolutos pré-showTypes, markersTotal post outros filtros). - Marker em múltiplos códigos (decisão iv) — aparece UMA vez sob primeira
marker.codes[]que sobrevive aocode filter. EmbyFile,codeIdsUsedreúne TODOS surviving (pra dar visão completa do arquivo). - markerLimit (5/10/25/all) — collapse por código com "Show N more". Threshold decisão pragmática; virtual scroll fica como follow-up (BACKLOG §17) se >500 marker memos virar dor.
Edição inline (hub editorial):
renderMemoEditor— textarea inline com debounced save 500ms +suspendRefresh/resumeRefresh(counter emAnalyticsView,scheduleUpdateno-op enquanto > 0). Pattern reusable pra qualquer view futura que precise textarea editável dentro do Analytics.onSaveHandlers— 5 kinds (code, group, relation code-level, relation app-level, marker). Cada um chama API certa do registry/dataManager.- Marker persistence:
dataManager.findMarker(engineType, markerId)→ mutação in-place +markDirty(). Adicionado emcore/dataManager.ts. Formaliza pattern já usado em menus (PDF/image escrevemmarker.memo = valuedireto). Ponto único de acesso, agnóstico a leaf aberta. - App-level relation memo:
setApplicationRelationMemo(codes, codeId, label, target, memo)emcodeApplicationHelpers.ts— primeira UI a escrever emCodeApplication.relations[i].memo(schema-ready desde #25). Mutação in-place +markDirty. Memo View vira a primeira surface app-level via ✎ inline.
Filtros:
- Reusa todos os filtros padrão do Analytics (sources, codes, code groups, case variables) via
buildFilterConfig. - Adiciona 3 controles próprios em
memoViewOptions: Group by radio, Show types (4 checkboxes), Marker limit dropdown.
Export:
- CSV:
buildMemoCSV(result)puro + wrapper download. Colunas:entity_type, entity_id, code_id, code_name, file_id, source_type, level, memo. Group memos dedup quando aparecem em múltiplos códigos. - Markdown:
buildMemoMarkdown(result, opts)puro + wrapper que cria nota emAnalytic Memos/YYYY-MM-DD.mde abre em nova leaf. Hierarquia indentada via H2→H6 (cap em H6 pra depth ≥ 5). Wikilinks pra files. Excerpt em blockquote. ModeEntry.exportMarkdown?opcional adicionado ao registry — botão "Export Markdown" no toolbar do Analytics aparece SÓ quando mode temexportMarkdowndefinido (declarativo, sem switch hardcoded).
Schema breaking — memo?: string virou memo?: MemoRecord = { content: string; materialized?: { path, mtime } } em CodeDefinition + GroupDefinition + BaseMarker + CodeRelation. Materialização de memo como .md no vault, com sync bidirecional via vault listeners. 4/4 tipos wirados: Code, Group, Marker, Relation (code-level + app-level).
Decisão fundadora: schema breaking (não aditivo) porque memo é uma coisa só conceitualmente. Aditivo (memo + memoFile? paralelos) inventaria sincronização desnecessária e fonte de bug. Plugin sem usuários (CLAUDE.md) autoriza breaking; ~30 pontos de toque mecânico via accessors.
Endereçamento universal — EntityRef:
type EntityRef =
| { type: 'code'; id: string }
| { type: 'group'; id: string }
| { type: 'marker'; engineType: EngineType; id: string }
| { type: 'relation-code'; codeId: string; label: string; target: string }
| { type: 'relation-app'; engineType: EngineType; markerId: string; codeId: string; label: string; target: string };Serializa pra string canônica em frontmatter (code:abc123, marker:pdf:m1, etc). 5-way union prepara extensão sem refactor de schema.
Frontmatter do .md materializado:
---
qualiaMemoOf: code:c_0001
qualiaCodeName: Wellbeing
---qualiaMemoOf é o ponteiro estável (sobrevive rename do .md, do code). qualiaCodeName é cosmético — espelho do nome atual pro Properties view do Obsidian; desatualiza quando user renomeia o code (sem efeito funcional).
Reatividade — 3 vault listeners:
| Evento | Handler | Comportamento |
|---|---|---|
vault.on('modify') |
onMaterializedFileModified |
Lê .md, parse frontmatter, atualiza entity.memo.content no data.json. Frontmatter quebrado → desmaterializa graciosamente (volta a inline). Frontmatter aponta pra ref diferente → no-op com console.warn. |
vault.on('rename') |
onMaterializedFileRenamed |
Atualiza materialized.path da entidade. Reverse-lookup map também atualiza. |
vault.on('delete') |
onMaterializedFileDeleted |
Remove materialized da entidade. content preservado — entidade volta automático pra inline. |
Reverse-lookup Map<path, EntityRef> mantido em memória (plugin.memoReverseLookup). Reconstruído no onload varrendo registry. O(1) lookup em event handler — sem ele, cada vault event seria O(n) sobre todas entidades.
Self-write tracker Set<string> (plugin.memoSelfWriting) — pattern reusável pra prevenir loop em vault listeners. Convert/syncToFile adicionam path antes de vault.modify/create, removem em queueMicrotask. Listener modify ignora paths nesse set. Pattern conhecido (Templater faz parecido). Documentado em TECHNICAL-PATTERNS.md.
Componentes (src/core/):
memoTypes.ts— MemoRecord, EntityRef + serializersmemoHelpers.ts— getMemoContent / setMemoContent / hasContent (centraliza acesso ao schema)memoNoteFormat.ts— parseMemoNote / serializeMemoNote (frontmatter + body)memoPathResolver.ts— sanitizeFilename + resolveConflictPath (sufixo(2)/(3))memoMigration.ts— migramemo: stringlegacy → MemoRecord no DataManager.load (idempotente)memoMaterializer.ts— convertMemoToNote / unmaterialize / syncFromFile / refreshMemoNote (Phase 3) + helpers genéricosresolveEntity/resolveFolder/readMemoRecord/writeMemo(switch porref.type). Phase 1+2 completa: code, group, marker, relation-code, relation-app.convertMemoToNote(plugin, ref, opts?)aceita{ openInTab?: boolean }(default true; batch passa false pra não abrir N abas).memoBatchMaterializer.ts(Phase 3) —collectAllMemoRefs(plugin)itera registry/markers/relations (espelharebuildMemoReverseLookup) retornando{ ref, memo }[].categorize(all, options)separa em 4 buckets:toCreate,toOverwrite,alreadyUpToDate,emptySkipped.materializeBatch(plugin, preview, onProgress?)itera buckets executando convert/refresh, reporta progresso via callback, captura erros individuais sem abortar batch.describeRef(plugin, ref)resolve label legível pra UI.materializeAllMemosModal.ts(Phase 3) — Modal acessível via command palettematerialize-all-memos. 3 estados de UI: form (toggles por kind + Include empty + Overwrite + preview live com 4 contadores), progress (status + barra + counter), resultados (✓/↻/✗ com details expansíveis). Field interno chamadobatchOptions(nãoselection— colide com prototype de Modal/Component, ver TECHNICAL-PATTERNS §32).memoMarkerNaming.ts(Phase 2 Marker) —buildMarkerFilename(plugin, ref)com estratégia híbrida por engine: texto =<file>-<excerpt-4-palavras>(threshold ≥3 palavras); pdf-shape/image =<file>-<shape>-<id-curto>; audio/video =<file>-<timecode>. Funções puras testáveis isoladas.detailRelationRenderer.ts(Phase 2 Relation) — Relation Detail view (code-level + app-level com banner contextual). Header com chips clicáveis source/target, Direction, Memo (textarea/card), Evidence list (só code-level), Delete.RelationContextdiscriminated union define o kind. Caller (baseCodeDetailView) injetaonSaveMemo(ref, content)callback pra rotear write pelo registry/dataManager.memoMaterializerListeners.ts— registerMemoListeners + rebuildMemoReverseLookup
UI: render condicional em 4 surfaces:
- Code Detail (
detailCodeRenderer.renderCodeMemo) — quandomemo.materializedexiste, textarea some e vira card📄 Materialized at <path>com botões Open / Unmaterialize. Botão "Convert to note" no header da seção quando ainda inline. - Group panel (
codeGroupsPanel.ts, quando group selected) — block do memo ganha botão "Convert to note" ao lado do texto (codebook-groups-memo-wrapflex layout). Quando materializado, block vira card compacto (codebook-groups-memo-cardvariant). - Marker focused detail (
detailMarkerRenderer.renderMemoSection) — mesmo pattern do Code Detail. Popovers de coding (image/media/pdf/markdown) e Memo View card ficam intocados — Convert é decisão analítica, popover é coding rápido. User materializa via marker detail (1 click do source chip do memoView). - Relation Detail (
detailRelationRenderer.renderRelationMemo) — view nova drill-down. Acessível via click em row de relation no Code Detail (code-level) ou Marker focused detail (app-level). Banner contextual distingue os 2 kinds. Memo no centro com Convert/card. Evidence list (só code-level) cruza markers que aplicam.
memoAccess?: MemoMaterializerAccess é opcional, injetado pelo plugin via BaseCodeDetailView constructor + propagado pra ListRendererCallbacks → CodeGroupsPanelCallbacks. Degrada gracioso quando não injetado.
API genérica via EntityRef: MemoMaterializerAccess.convertMemo(ref) / unmaterializeMemo(ref) aceitam qualquer tipo (5-way union); switch interno em memoMaterializer.ts resolve. EntityRef cobre code, group, marker, relation-code, relation-app — todos wirados.
Quirk de notification — code/group vs marker: code e group passam por registry.update / setGroupMemo que disparam onMutateListeners → main.ts dispatch qualia:registry-changed. Marker muta direto via dataManager.findMarker (não passa pelo registry), então writeMemo/syncFromFile pra marker dispara dispatchEvent('qualia:registry-changed') explícito. Sem esse emit, BaseCodeDetailView não refresh e o card materializado não aparece.
Smart Open: openMaterializedFile reusa leaf existente (iterateAllLeaves filtrado por view.file.path) antes de criar nova aba. Evita poluir workspace com tabs duplicadas em clicks múltiplos.
Settings — memoFolders:
memoFolders: {
code: 'Analytic Memos/Codes', // Phase 1 ativo
group: 'Analytic Memos/Groups', // reservado
marker: 'Analytic Memos/Markers', // reservado
relation: 'Analytic Memos/Relations', // reservado
}Settings tab mostra os 4 inputs (3 disabled). Defaults criam folder hierarchy on demand via vault.createFolder.
Não-objetivos Phase 1:
- UI pra Group/Marker/Relation (extensão futura mecânica)
- Templater integration
- Materialização batch
- Reconciliação de
qualiaCodeNamequando user renomeia o code (cosmético, fica desatualizado)
Design doc autoritativo:
docs/parquet-lazy-design.md. Esta seção registra apenas as decisões arquiteturais consolidadas após a Fase 5 (2026-05-04).
Carregar parquet de centenas de MB ou milhões de rows na memória do plugin trava o Obsidian. Premissa do projeto: mesma UX de coding (filter/sort/code/batch) independente de o arquivo caber na RAM.
Stack: DuckDB-Wasm (engine SQL embedded no plugin) + OPFS (Origin Private File System pra storage local com partial-read) + AG Grid Infinite Row Model (paginated grid).
- Arquivo > threshold (default: parquet 50 MB / csv 100 MB) → banner Lazy/Eager/Cancel.
- Lazy mode →
copyVaultFileToOPFSstreama o arquivo do vault pra OPFS em chunks de 1 MB (Premise C: heap delta = 0). Idempotente viamtime. DuckDBRowProvider.createregistra o file handle, materializa tabelaqualia_lazy_<id>com coluna sintética__source_row(= papaparse row index, parity com eager).- AG Grid Infinite paginates via
getRows(params)→ traduzparams.sortModel+params.filterModelpra SQL →DuckDBRowProvider.getRowsByDisplayRangeretorna a página. display_row mapping(DuckDB temp table) cacheia__source_row → display_rowsob sort+filter atual pranavigateToRowem O(1).
defaultColDef.filter: true habilita o filter UI nativo do AG Grid em colunas reais. Cada filterChanged:
gridApi.getFilterModel()→ AG Grid filterModel (text/number/combined).buildWhereClause(filterModel)(emsrc/csv/duckdb/filterModelToSql.ts, helper puro com 19 testes) traduz pra SQLWHEREescapado.LazyState.currentFilter = { whereClause, filteredCount }é atualizado sincronamente (AG Grid re-fetcha imediato; sem race) + asyncgetRowCount(whereClause)pralastRowcorreto.displayMapé rebuilded com o whereClause (rows filtradas têm display_row reordenado).
Virtual columns (cod-frow/cod-seg/comment) mantêm filter: !lazy por-coluna (não estão no DuckDB schema). Filter delas via lazy seria LEFT JOIN com data.json — fora de escopo.
Tag button no header de coluna cod-frow abre openBatchCodingPopover, que é mode-agnostic: recebe callback getFilteredSourceRowIds: () => Promise<number[]>.
- Eager: callback wrapeia
gridApi.forEachNodeAfterFilterAndSort. - Lazy: callback chama
rowProvider.getFilteredSourceRowIds(whereClause)—SELECT __source_row WHERE ...via DuckDB. Acessa o Arrow vector direto (10× mais rápido quer.toJSON()).
Aplicação em massa: CsvCodingModel.addCodeToManyRows / removeCodeFromManyRows / removeAllRowMarkersFromMany. Single-pass index build (O(M)) + iterate sourceRowIds (O(R)) + ÚNICO notify() ao final. Reduz batch em 661k rows de minutos pra ~1-3s.
getCodeIntersectionForRows calcula codes presentes em todas as rows visíveis em O(M+R) com early-exit. Skipped acima de 5000 rows (interseção é praticamente sempre vazia em datasets enormes; recompute é desperdício).
onUnloadFile snapshots lazyState e seta null ANTES de gridApi.destroy() ou da teardown async (dropDisplayMap, dispose). Concurrent paths (refreshLazyDisplayMap, refreshLazyFilter, datasource em flight) re-checam this.lazyState após cada await e abortam silenciosamente se virou null. Sem isso, sessões com filter rápido + troca de arquivo emitem "DuckDBRowProvider has been disposed" no console.
Heurística: app.workspace.layoutReady === false durante restoration. Arquivos > threshold mostram placeholder inerte "Click to open this file" em vez de auto-disparar o banner. Resolve "Obsidian travado eternamente" ao reabrir vault com parquet pesado na leaf — auto-load competia com plugin parsing (49MB bundle) por thread.
Anti-pattern descoberto: await this.confirmLoadLargeFile(...) dentro de onLoadFile prende o loadFile interno do Obsidian. Workspace inteiro paralisa (até markdown não abre) até o user clicar em algum botão do banner.
Fix: extraído loadEagerPath(file). onLoadFile retorna IMEDIATAMENTE após renderizar o banner; botões disparam o próximo passo via .then(). Cada callback faz if (this.file !== file) return pra desistir se o user trocou de arquivo enquanto o banner estava aberto.
Lazy mode lê cellText de DuckDB+OPFS. Sidebar (SidebarModelInterface.getMarkerText) é sync — não pode await. Backlog original sugeria cascade async em getAllMarkers / getMarkerById / getMarkersForFile → Promise<...> (12+ sites em core/). Foi rejeitado por contaminar drag-drop, hover e listas que não precisam de markerText.
Solução adotada: cache derivado em CsvCodingModel.markerTextCache: Map<markerId, string>.
populateMarkerTextCacheForFile(fileId, provider)no lazyonLoadFile: chunked (1000 markers/batch) + dedup por(sourceRowId, column)viabatchGetMarkerText. Aplicafrom..tosubstring em segment markers.populateMissingMarkerTextsForFile(fileId, provider)invocado viamodel.onChangelistener debounced 100ms — top-up após batch coding sem refetch dos já cacheados. Retornaaddedpro caller decidir se chamanotifyListenersOnly.getMarkerText(sync) consulta cache →rowDataCache(eager) → null.getMarkerTextAsynccobre o caminho lazy on-demand e popula cache no hit.- Invalidação granular nos 6 sites de remove:
removeMarker,removeAllMarkersForFile,clearAllMarkers,deleteSegmentMarkersForCell,removeCodeFromManyRows,removeAllRowMarkersFromMany. Cada um fazmarkerTextCache.delete(id)antes de splice/filter. - Cleanup per-file no
onUnloadFile(clearMarkerTextCacheForFile).
Trade-off: ~5MB RAM por file aberto com 10k markers (custo previsível, limpa no unload). +200-500ms no open de file lazy com 10k markers (chunked batch). Todos os outros engines (markdown/pdf/image/audio/video) continuam sync sem qualquer mudança — só CSV precisa do cache porque é o único cuja cellText vem de IO assíncrono.
CsvCodingModel.notify() chama saveMarkers() antes de disparar listeners. Pra cache populates (rowDataCache eager + markerTextCache lazy), persistir o data.json é desnecessário — o cache é derivado do file no disco. notifyListenersOnly() foi adicionado pra triggerar re-render da sidebar sem write.
Usos: csvCodingView chama após rowDataCache.set no eager path e após populateMarkerTextCacheForFile / populateMissingMarkerTextsForFile no lazy path. Também: MarkerPreviewHydrator.scheduleNotify (debounced via RAF) chama após batch popular markerTextCache — re-render dos consumers cobre cross-engine sem duplicar canal.
prepopulateMarkerCaches.ts (Fase 6 Slice A) só popula lazy se OPFS já tem cópia fresca — vault migrado (QDPX import) não tem OPFS. Sem hydrator, sidebar mostrava Row N · column (coordenada placeholder via getMarkerLabel) ad eternum até user abrir manualmente cada parquet.
Solução: orchestrator stateful em src/csv/markerPreviewHydrator.ts que ataca on-demand quando consumers renderizam:
- State:
seen: Set<fileId>(sucesso ou skipped permanente),inflight: Map<fileId, Promise>(dedup),errors: Map<fileId, string>(retry next time). - API:
requestHydration(fileId)idempotente. Wrapper IIFE garanteinflight.setantes do batch (eager path síncrono não pode deletar entry antes do set acontecer — bug 2026-05-06).onStatusChange(listener)pro indicator visual.markSeen(fileId)chamado porprepopulateMarkerCachesno eager path pra evitar revisita.dispose()aguarda inflight com timeout 5s + cancela RAF pending. - Provider reuse:
csvModel.getLazyProvider(fileId)— se file aberto pelo user, reusa provider; senão cria próprio (copyVaultFileToOPFSse OPFS frio +DuckDBRowProvider.create) e dispose ao fim. Race comcsvCodingView.onCloseé catch-handled (provider mid-dispose → throw → outcome error → retry next). - Single source of truth pra OPFS lazy:
prepopulateMarkerCacheslazy path foi removido (race com hydrator criavacreateSyncAccessHandleconflict).
Consumers (todos chamam requestHydration(fileId) per-file durante render): BaseCodeExplorerView.buildCodeIndex, detailCodeRenderer (Segments by file), detailRelationRenderer (evidence list, dedup local de fileIds), detailSmartCodeRenderer (groupedByFile), smartCodeListModal (via callback do detail), memoViewMode (by-code/by-file mode, filtra kind === 'marker').
Re-render: ao completar batch com addedCount > 0, hydrator chama csvModel.notifyListenersOnly() debounced via RAF (coalesce múltiplos batches concorrentes em 1 notify). Consumers re-renderizam coalescidos via mecanismo existente — markerToBase retorna texto sync via cache hit.
Helper genérico: viewport rendering com row pool diff. Itens fora do viewport (+ buffer) NÃO ficam no DOM. Scroll mounta novos, evicta os que saíram.
Por que existe. baseCodeExplorerView, detailCodeRenderer (markers list + segments by file) e detailRelationRenderer (evidence list) iteravam todos os markers e criavam 1 <div> + listeners por marker. Vault de teste com batch coding em parquet (665k row markers, 661k num único code) travava o UI thread por segundos quando o user expandia a sidebar. codebookTreeRenderer já tinha virtual scroll bespoke pra árvore de codes — virtualList.ts extraiu a mecânica em 2026-05-04 (4e9a9cd). Em 2026-05-13 (ca68dbf) o renderer da árvore foi migrado pra também consumir o helper, eliminando o pattern duplicado; tree-specific concerns (folders, depth, drag-drop dataset attrs, selected state, group filter) seguem no renderRow callback do consumer.
API:
const list = createVirtualList<BaseMarker>({
container: scrollEl, // height-constrained via CSS
rowHeight: 26,
buffer: 5, // rows extras out-of-viewport
renderRow: (marker, idx) => buildRowEl(marker),
});
list.setItems(markers); // troca itens (drop pool + recompute spacer)
list.refresh(); // re-render preservando itens (hover state changes etc)
list.cleanup(); // remove scroll listener (idempotente)Containers: max-height: 50-60vh + overflow-y: auto + position: relative. Spacer interno reserva altura virtual completa; rows são position: absolute; top: ${i * rowHeight}px. Pra files com poucos markers, naturalHeight = items.length * rowHeight é menor que maxByVh, então container fica em altura natural sem scroll forçado.
Limitação: introduz nested scroll dentro do sidebar (preferível à UI travada). Files muito pequenos com altura natural não têm overflow — cosmético.
Antes vivia no CLAUDE.md, movido pra cá em 2026-05-05 pra reduzir CLAUDE.md inflado. Listagem por módulo + responsabilidade. Não é exaustiva — arquivos triviais (1-2 funções auto-explicativas) podem ser omitidos. Pontos de entrada e arquivos com responsabilidade não-óbvia entram aqui.
src/
main.ts — entry point (QualiaCodingPlugin)
obsidian-internals.d.ts — ambient types (Editor.cm, posToOffset, workspace events)
core/
baseSidebarAdapter.ts — base class para TODOS os sidebar adapters (listeners, hover, deleteCode, updateMarkerFields)
markerResolvers.ts — type guards (isPdfMarker etc.) + getMarkerLabel + shortenPath
codeApplicationHelpers.ts — hasCode, getCodeIds, addCodeApplication, removeCodeApplication, getMagnitude, setMagnitude, getRelations, addRelation, removeRelation
baseCodingMenu.ts — helpers compartilhados de menu (createActionItem, applyThemeColors, renderMagnitudeSection, renderRelationsSection)
relationUI.ts — renderAddRelationRow compartilhado (popover, detail, marker detail)
relationHelpers.ts — collectAllLabels, buildRelationEdges (funcoes puras)
hierarchyHelpers.ts — buildFlatTree, buildCountIndex, getDirectCount, getAggregateCount
codebookTreeRenderer.ts — árvore hierárquica com pastas; delega virtual scroll pra createVirtualList
virtualList.ts — helper genérico de virtual scroll (rowPool diff + RAF fallback); consumed por codebookTreeRenderer, baseCodeExplorerView, detailCodeRenderer, detailRelationRenderer
codebookContextMenu.ts — context menu codigos + pastas (Rename, Delete, Move to folder)
codebookDragDrop.ts — drag-drop lifecycle: reparent, merge, move to folder
detailListRenderer.ts — "All Codes" list mode + toolbar + opcionalmente Smart Codes section integration
detailCodeRenderer.ts — code-focused detail (name, color, description, hierarchy, markers)
detailMarkerRenderer.ts — marker-focused detail (excerpt, codes, memo, color override)
detailRelationRenderer.ts — Relation Detail: header com chips + banner code/app + Memo + Evidence + Delete
baseCodeDetailView.ts — abstract base: 4-mode navigation (list / code / marker / smartCode) + relation
baseCodeExplorerView.ts — abstract base: Code Explorer tree (Code → File → Segment)
navigateToMarker.ts — engine-aware navigation helper (md inline, others via workspace events)
mergeModal.ts — MergeModal expandido (4 seções reativas + executeMerge reordenado)
mergePolicies.ts — helpers puros pro merge: resolveName/resolveColor/applyTextPolicy
dialogs.ts — PromptModal / ConfirmModal genéricos (substituem prompt/confirm nativos)
imageDimensions.ts — getImageDimensions com fallback createImageBitmap → <img>
magnitudeRange.ts — generateContinuousRange puro (decimais inferidos do step, safety cap)
drawToolbarFactory.ts — factory compartilhada de toolbar drawing (PDF + Image)
codeGroupsPanel.ts — painel "Groups" no topo do codebook (chips + filter contextual)
codeGroupsAddPicker.ts — getAddToGroupCandidates puro (popula FuzzySuggestModal)
mediaViewTypes.ts — constantes isoladas de view type
viewToggleHelpers.ts — lógica pura: resolveToggleTarget, isMediaViewType
mediaToggleButton.ts — injeção do botão `replace-all` no header + performToggleCommand
fileInterceptor.ts — intercept unificado + pinnedFileByLeaf pra respeitar swap manual
codeVisibility.ts — helpers puros: isCodeVisibleInFile, shouldStoreOverride, cleanOverridesAfterGlobalChange
codeVisibilityPopover.ts — popover compartilhado (body render + open floating)
visibilityEventBus.ts — rAF coalescing bus (singleton) pra notificar views em rajadas
caseVariables/ — Case Variables: propriedades tipadas por arquivo (mixed-methods)
memoTypes.ts — MemoRecord, MaterializedRef, EntityRef (6-way union: code/group/marker/relation-code/relation-app/smartCode) + serializers
memoHelpers.ts — getMemoContent / setMemoContent / hasContent
memoNoteFormat.ts — parse/serialize de memo notes (frontmatter `qualiaMemoOf`)
memoPathResolver.ts — sanitizeFilename + resolveConflictPath
memoMigration.ts — migra `memo: string` legacy → MemoRecord (idempotente, cobre code/group/marker/relation/smartCode)
memoMaterializer.ts — convertMemoToNote / unmaterialize / syncFromFile / refreshMemoNote (cobre 6 entity types)
memoMaterializerListeners.ts — vault.on(modify/rename/delete) + reverse-lookup Map + self-write Set
memoMarkerNaming.ts — buildMarkerFilename híbrido por engine (excerpt / shape / timecode)
memoBatchMaterializer.ts — collectAllMemoRefs + categorize + materializeBatch com onProgress + describeRef (cobre 6 kinds)
materializeAllMemosModal.ts — modal command palette com 3 estados (form / progress / results)
getAllMarkers.ts — iterador cross-engine pra qualquer derivação que precisa varrer todos markers
smartCodes/ — Smart Codes (Tier 3): códigos virtuais por predicate (padrão ATLAS.ti)
types.ts — re-exports + isOpNode/isLeafNode
predicateSerializer.ts — predicateToJson canonical key order + predicateFromJson
dependencyExtractor.ts — extractDependencies pra invalidação granular
predicateValidator.ts — validateForSave: empty/cycle/name-collision/broken-ref/magnitude/incomplete-leaf
evaluator.ts — evaluate puro com 2 switches, short-circuit, magnitude parse, cycle guard
cache.ts — SmartCodeCache: indexes + dirty set + rAF subscribers + onSmartCodeChanged incremental + computePreview
smartCodeRegistryApi.ts — SmartCodeRegistry classe (Pattern A): state interno + addOnMutate + setAuditListener + toJSON. SmartCodeAuditEvent typed (sem any)
builderTreeOps.ts — helpers puros AST: getNodeAt/addChildToGroup/removeNodeAt/moveNode/changeOperator/replaceLeafAt
builderModal.ts — Modal Obsidian 3 zonas: name+color+memo, tree row-based linear, preview live debounced 300ms, FuzzySuggest pra code/folder/group/casevar/smartcode pickers, inline errors
detailSmartCodeRenderer.ts — render Smart Code Detail (header + memo com Convert to note + Materialized card + query + matches engine-rich + history + delete). Visual reusa codemarker-detail-* classes
smartCodeListModal.ts — hub modal (Cmd+P): lista + new + click abre detail INLINE no sidebar OR modal detail
smartCodesSection.ts — renderSmartCodesSection wirado no Code Detail "All Codes" mode (section colapsável + eye toggle + 3-dot menu + new btn). PromptModal/ConfirmModal (sem window.prompt/confirm)
icr/ — ICR (Inter-Coder Reliability): infraestrutura compartilhada de multi-coder. Ver §19 pro mapa completo.
coderTypes.ts — Coder, CoderRun, CoderId types + DEFAULT_CODER_ID
coderRegistry.ts — CoderRegistry classe (createHuman/createLLM + addOnMutate + toJSON, mesmo padrão de CodeDefinitionRegistry)
sourceHashTypes.ts — SourceHashEntry types
computeSourceHash.ts — função pura SHA-256 via SubtleCrypto
sourceHashRegistry.ts — registry stateful (getOrCompute lazy + recompute + rename/remove + findByHash + addOnMutate events compute/recompute/rename/remove)
kappaInput.ts — KappaInput shape (text-likes/temporal) + char-level explosion + iterateAllUnitKeys
categoricalKappaInput.ts — CategoricalKappaInput shape (cod row, sem geometria) + extractRowMarkerUnit
textRange.ts — TextRange + adapters (extractMarkdownRange, extractPdfRange, extractCsvSegmentRange, extractMediaRange)
overlap.ts — computeOverlap puro (intersection 1D com scope check fileId+locator)
bboxNormalize.ts — bridge PdfShapeMarker.coords ↔ ImageMarker.coords → PercentShapeCoords comum
bboxRaster.ts — rasterize(shape, coords, gridSize) → Bitmap (Uint32Array packed) com clip-to-viewport
bboxIoU.ts — iou(a, b) com AABB early-out + AND/popcount32 SWAR
bboxMatcher.ts — Hungarian/Munkres assignment (O(max(N,M)³)) + match() com θ post-cutoff → AlignmentEvent[]
bboxKappaInput.ts — fromEvents: alignment events → CodedMarker[] (matched colide no mesmo i, unmatched ocupam units sequenciais)
bboxAdapter.ts — entry point per-pair: scope grouping (PDF page/Image fileId), adaptive resolution 400×400 trigger, pre-handla casos 0×N e N×0
reporter.ts — reportKappa: per-engine + aggregate ponderado + aggregateWarnings cross-unit (chars/seconds/categorical/spatial-bbox)
coefficients/
cohenKappa.ts — Cohen κ pareado per-char (Po-Pe normalizado)
fleissKappa.ts — Fleiss κ N-coders per-char
krippendorffAlpha.ts — Krippendorff α nominal per-char (coincidence matrix Do/De)
alphaBinary.ts — α-binary (collapse codes pra two-level boundary detection)
cuAlpha.ts — cu-α (code agreement within shared boundaries — filter chars + reuse αNominal)
cohenKappaCategorical.ts — Cohen κ sobre unit-level decisions (cod row)
fleissKappaCategorical.ts — Fleiss κ categórico
krippendorffAlphaCategorical.ts — Krippendorff α nominal categórico
sourceSize/ — providers de tamanho real do source per engine (substitui fallback `max(range.to)` que infla P_o em coding esparso)
mediaSourceSize.ts — duration de audio/video via HTMLMediaElement.duration (preload metadata, detached)
pdfSourceSize.ts — chars por página via `window.pdfjsLib` (caller fallback se PDF não foi aberto na sessão — não force-loadeamos em background)
csvSegmentSourceSize.ts — chars da célula: eager (rowDataCache, CSV pequeno) → lazy (RowProvider via DuckDB, CSV/parquet >100MB)
compositeSourceSize.ts — delega por engine entre providers concretos; primeiro non-null vence
transport/
payloadTypes.ts — PayloadV1, ConflictRecord, ExtractResult, MergeResult
computeCodebookHash.ts — SHA-256 determinístico do codebook (sort por id, ignora createdAt/updatedAt)
extractCoderContribution.ts — função pura: filtra markers por coderId + coleta codes/groups/sources/coder + computa codebookVersion
crossVaultRemap.ts — função pura: lookup hash no registry local → remapeia fileId; emite source_hash_mismatch / multiple_hash_matches / source_not_found
mergeCoderContribution.ts — função pura: aplica payload via mutação (codebook divergence + coder reg + remap + code/group merge + marker insertion)
ui/ — Compare Coders View (Slice E1, 2026-05-10) — UI ICR primeira camada
compareCodersTypes.ts — CompareCodersViewState + CurrentSelection + ComparisonScope + ComparisonFilters + createDefaultViewState
unifiedCompareCodersView.ts — ItemView shell: toolbar sticky + 2 mode pickers (matrix active, table/heatmap E2) + estado central + delega renders
overviewMatrix.ts — Mode A matriz coder×coder: Cohen κ pareado via reportPairwise + color scale fixo (qc-kappa-low/-mid-low/-mid-high/-high) + click→pair selection
scopeExtraction.ts — cohort-level adapter: itera 5 engines (md/pdf/csvSegment/csvRow/audio/video) + filter por scope + delega per-marker extractors dos slices 1+4. vault.cachedRead pra source text de markdown. Bbox engines pulados (E2)
drilldownSpatial.ts — P1 spatial: lanes per coder com [code-label] colorido (text-likes); csv-row delega pra csvCodingView.setCompareMode (cellStyle real no AG Grid)
compareModeColoring.ts — helpers puros: computeRowGradient (gradient CSS N stripes por coder) + computeRowMarkersByCell (Map<sourceRowId::column, markers[]>)
filterChips.ts — toggle coders + "destacar conflitos" + "esconder agreement total" → mutate state.filters
... — DataManager, CodeDefinitionRegistry, settings, types
markdown/ — CodeMirror 6 engine para markdown
cm6/
markerViewPlugin.ts — ViewPlugin orquestrador
handleOverlayRenderer.ts — SVG drag handles
dragManager.ts — ciclo de vida do drag
marginPanelLayout.ts — layout algorithm puro
pdf/ — PDF viewer + coding (fabric.js)
pdfCodingTypes.ts — PdfMarker, PdfShapeMarker (markerType: 'pdf')
pdfCodingModel.ts — model CRUD (indices DOM-alinhados)
selectionCapture.ts — captura seleção → indices via hitTestTextLayer
highlightRenderer.ts — pinta rects via textDivs
dragHandles.ts — handle drag → updateMarkerRange
pageObserver.ts — lifecycle: textlayerrendered → renderPage
pdfPlainText.ts — buildPlainText (export)
pdfExportData.ts — loadPdfExportData (export)
resolveMarkerOffsets.ts — marker.text → offset absoluto
extractAnchorFromPlainText.ts — slice → text/page (import)
resolvePendingIndices.ts — text-search → indices (import runtime)
csv/ — CSV/Parquet engine (ag-grid, papaparse, hyparquet, duckdb-wasm)
csvCodingTypes.ts — SegmentMarker, RowMarker, CsvMarker (markerType: 'csv')
csvCodingModel.ts — model CRUD + bulk row ops + lazy providers + markerTextCache
csvCodingView.ts — FileView orquestrador (eager + lazy paths)
markerPreviewHydrator.ts — orchestrator stateful pra hidratação on-demand (lazy)
parseTabular.ts — parseTabularFile compartilhado (papaparse + hyparquet)
prepopulateMarkerCaches.ts — pre-populate de markerTextCache no startup
resolveExportTexts.ts — resolve cellText pra export (6 cases: eager/lazy × aberto/fechado)
lazyProgressFormat.ts — formatLazyProgress puro
csvCodingMenu.ts — popovers de codificacao
csvCodingCellRenderer.ts — cell renderer AG Grid
segmentEditor.ts — CM6 split panel
columnToggleModal.ts — Modal de settings de colunas + CommentCellEditor
csvHeaderInjection.ts — MutationObserver pros headers AG Grid
duckdb/ — Lazy mode infra: DuckDB-Wasm + OPFS + filter SQL
duckdbBootstrap.ts — createDuckDBRuntime() factory + 2 shims pro Worker em Electron
duckdbRowProvider.ts — DuckDBRowProvider pra eager + lazy modes
filterModelToSql.ts — buildWhereClause(filterModel) → SQL escapado
opfs.ts — copyVaultFileToOPFS streaming + isOpfsCached + removeOPFSFile
rowProvider.ts — interface RowProvider + MockRowProvider
wasmAssets.ts — WASM bytes embedded gzipados (32.7MB → 7.6MB)
image/ — Image coding (fabric.js, zoom/pan per-file)
imageCodingTypes.ts — ImageMarker (markerType: 'image'), RegionShape, NormalizedCoords
imageCodingModel.ts — model CRUD + persistence
imageCodingMenu.ts — lifecycle wrapper
imageToolbar.ts — toolbar de drawing
regionHighlight.ts — hover glow effect
regionLabels.ts — labels de codigo sobre regioes
canvas/ — Fabric.js canvas, drawing, zoom/pan
audio/ — Audio engine — thin wrapper via MediaViewCore
video/ — Video engine — thin wrapper via MediaViewCore
export/ — REFI-QDA export (QDC codebook + QDPX) + CSV tabular
qdcExporter.ts — gera XML do codebook
qdpxExporter.ts — orquestra export completo (incl. <qualia:SmartCodes> namespace)
xmlBuilder.ts — helpers XML
coordConverters.ts — conversao de coords por engine
exportModal.ts — modal pre-export
exportCommands.ts — commands na palette
caseVariablesXml.ts — QDPX helpers pra cases/variables
tabular/ — CSV zip export pra R/Python/BI
csvWriter.ts — primitivo CSV (RFC 4180 + UTF-8 BOM)
readmeBuilder.ts — gera README.md embutido
buildSegmentsTable.ts — consolida 8 sourceTypes
buildCodeApplicationsTable.ts — 1 linha per (segment, code)
buildCodesTable.ts — codebook denormalizado
buildCaseVariablesTable.ts — long format
buildRelationsTable.ts — unifica code-level + application-level
buildGroupsTable.ts — groups.csv standalone
buildSmartCodesTable.ts — smart_codes.csv (incluindo memo.content + matches_at_export)
tabularExporter.ts — orchestrator (incl. csvModel access pra cell text)
import/ — REFI-QDA import (QDC + QDPX)
qdcImporter.ts — parse XML codebook
qdpxImporter.ts — orquestra import completo (incl. parseSmartCodes 2-pass + remap codeId)
xmlParser.ts — helpers parse XML
importModal.ts — modal de import
importCommands.ts — commands na palette
analytics/ — Charts e word clouds (chart.js)
data/
consolidationCache.ts — cache incremental por engine
dataConsolidator.ts — 6 funcoes puras por engine + consolidate()
dataReader.ts — readAllData(DataManager)
relationsEngine.ts — extractRelationEdges/Nodes (Network View)
statsEngine.ts — barrel re-export (frequency, cooccurrence, evolution, sequential, inferential, textAnalysis, codeMetadata)
statsHelpers.ts — applyFilters compartilhado
inferential.ts — calculateChiSquare puro
binning.ts — binNumeric/binDate/explodeMultitext puros
codeMetadata.ts — calculateCodeMetadata + chi²
memoView.ts — aggregateMemos pura
codebookTimelineEngine.ts — Codebook Timeline helpers
clusterEngine.ts — hierarchicalCluster + buildDendrogram + cutDendrogram + calculateSilhouette (puro, sync)
cluster.worker.ts — Worker inline pro hierarchicalCluster + computeClusterArtifacts (off-main-thread, evita UI freeze em codebook grande)
clusterWorkerClient.ts — Promise-based client; `hierarchicalClusterAsync` / `computeClusterArtifactsAsync` consumidos por cooccurrence/overlap/dendrogram (fire-and-forget + isRenderCurrent guard)
clusterSyncFallback.ts — fallback sync pra jsdom em tests (sem Worker)
board/ — Research Board (Fabric.js)
views/
analyticsView.ts — classe AnalyticsView
analyticsViewContext.ts — interface + type aliases
configSections.ts — config panel sections compartilhadas
shared/chartHelpers.ts — heatmapColor, computeDisplayMatrix, etc
modes/ — 23 mode modules (1 por visualizacao)
media/
mediaTypes.ts — MediaMarker (markerType: 'audio' | 'video'), MediaFile, BaseMediaSettings
mediaViewCore.ts — logica compartilhada audio/video via composicao
mediaViewConfig.ts — interface de configuracao
mediaCodingModel.ts — base class generica
mediaCodingMenu.ts — popover compartilhado
mediaSidebarAdapter.ts — sidebar adapter compartilhado
regionRenderer.ts — renderizacao de regioes (wavesurfer)
waveformRenderer.ts — wrapper WaveSurfer.js
formatTime.ts — helper de formatacao de tempo
Adicionado 2026-05-09. Frente entregue em 4 slices: motor κ texto, hash por source, transport multi-coder remoto, adapters cod row + áudio/vídeo. Cobre 5 das 6 engines do plugin (PDF shape + imagem ficam pra slice futuro com brainstorm metodológico — bbox IoU em QDA é terreno aberto).
┌─────────────────────────────────┐
│ CoderRegistry (Slice 1) │
│ ── seed default + create* │
└────────────┬────────────────────┘
│
┌───────────────┼────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌──────────────────┐
│ Adapters │ │ Motor κ │ │ SourceHash │
│ por engine │→│ paramétric │ │ Registry │
│ (Slice 1+4) │ │ (Slice 1) │ │ (Slice 2) │
└──────────────┘ └─────────────┘ └────────┬─────────┘
│ │
▼ │
┌────────────────────┐ │
│ Reporter │ │
│ per-engine + agg │ │
│ (Slice 1+4) │ │
└────────────────────┘ │
▼
┌──────────────────┐
│ Transport │
│ multi-coder │
│ (Slice 3) │
│ ── extract │
│ ── merge + remap│
└──────────────────┘
-
codedBy: CoderIdunificado — humano e LLM no mesmo eixo de schema ('human:default','human:carla','llm:gpt-4o'). Função pura κ não distingue tipo de coder. Verobsidian-qualia-coding/plugin-docs/research/ICR-DESIGN-SKETCH-2026-05-08.md §3. -
Função pura κ paramétrica por geometria de overlap — adapter por engine traduz marker pra
TextRangenormalizado (texto-likes + temporal) ouCategoricalUnit(cod row, sem geometria). Coeficientes operam sobre representação genérica:- Per-char (markdown / PDF text / CSV cod segment): char é unit; cu-α / α-binary / Cohen κ / Fleiss κ / Krippendorff α
- Per-segundo (audio / video): segundo é unit; reusa coeficientes texto-likes (espaço de coordenadas troca, álgebra não)
- Categorical (CSV cod row): unit pré-definida (file + row + column); coeficientes próprios sobre unit-level decisions sem char explosion
-
Hash SHA-256 como primitiva arquitetural transversal — não é só "validação pra ICR"; serve cache invalidation cirúrgica (markerTextCache), rename detection (
vault.on('rename')), QDPX import dedup, cross-vault remap (transport multi-coder). -
Transport multi-coder via funções puras — payload JSON v1.0 com
codebookVersionhash +coderfull +sourcescom hash +codesreferenciados +markersper engine.extractCoderContributionfiltra subset por coderId;mergeCoderContributionaplica via mutação direta com cross-vault remap embutido. UI ainda não existe (Fase C P1, gated em UX brainstorm).
5 pra texto-likes/temporal + 3 pra categorical = 8 coeficientes ao todo:
| Coeficiente | Texto-likes / Temporal | Categorical |
|---|---|---|
| Cohen κ pareado | ✅ | ✅ |
| Fleiss κ N-coders | ✅ | ✅ |
| Krippendorff α nominal | ✅ | ✅ |
| α-binary (boundary) | ✅ | n/a (vacuous=1) |
| cu-α (code-within-boundary) | ✅ | n/a (vacuous=1) |
Reporter retorna byEngine: Record<EngineId, CoefficientReport> + aggregate (média ponderada por #markers ou #units) + aggregateWarnings (string[]) emitido quando engines de unidades incomparáveis (chars vs seconds vs categorical) entram juntos no aggregate.
type EngineId = 'markdown' | 'pdf' | 'csvSegment' | 'csvRow' | 'audio' | 'video';Cobertura por engine:
| Engine | Geometria | Algoritmo | Slice |
|---|---|---|---|
| markdown | per-char offset linear | per-char | 1 |
per-char page-aware (page:N) |
per-char | 1 | |
| csvSegment | per-char dentro de cell range | per-char | 1 |
| csvRow | unit pré-definida | categorical | 4 |
| audio | overlap temporal segundos (Math.floor/ceil) | per-second | 4 |
| video | overlap temporal segundos | per-second | 4 |
| pdf shape | bbox IoU (não implementado) | bbox IoU | futuro (brainstorm precede) |
| image | bbox IoU (não implementado) | bbox IoU | futuro (brainstorm precede) |
SourceHashRegistry — stateful classe com Map<fileId, SourceHashEntry>. Pattern de CodeDefinitionRegistry (addOnMutate listeners + toJSON/fromJSON). Eventos: compute (primeira vez), recompute (mudou), rename (path movido), remove (deletado). Hooks no plugin onload:
vault.on('rename')→renameEntry(oldPath, newPath)vault.on('delete')→removeEntry(path)vault.on('modify')→ se file tracked,recompute(path); se hash mudou, invalidacsvModel.markerTextCachepra esse fileId
QDPX import dedup: extractSource() chama findByHash(incomingHash) antes de criar duplicata em imports/<projectName>/.
extractCoderContribution(data, coderId, hashRegistry) filtra markers por codedBy + coleta deps (codes/groups/sources/coder) + computa codebookVersion. Retorna { payload: PayloadV1, warnings: string[] }.
mergeCoderContribution(localData, payload, hashRegistry) aplica payload via mutação:
- Codebook divergence → emit
codebook_divergedconflict (warning, não bloqueia) - Coder registration se ausente
- Cross-vault remap:
crossVaultRemap(payload.sources, hashRegistry)retornafileIdRemap+ conflicts (source_hash_mismatch/multiple_hash_matches/source_not_found) - Code merge: incoming wins on diff + emit
code_overwrittenconflict - Group merge: skip se existe (não-overwrite, conservador)
- Marker insertion per engine com
fileIdremapped
Plugin expõe icrTransport.extract(coderId) / merge(payload) no main, chamável via console DevTools. UI fica em Fase C P1 (gated em UX brainstorm).
- Adapter PDF shape + imagem (bbox IoU) — terreno aberto, brainstorm metodológico precede
- Fase C P1 (UX layer) — comando export, modal preview, side-by-side compare, cherry-pick, conflict resolution UX, multi-import staging, codebook divergence UX, source divergente alert
- Smart Code cache hash invalidation — adiado (predicates atuais não dependem de texto)
- Backup integrity validation — adiado (semântica fragmentada, restore raro)
- Resolução sub-segundo audio/video — otimização futura
- Pre-warm de durações de media files — otimização futura
Primeira camada de UI ICR. Cobre overview Mode A + drill-down P1 spatial + filter chips. E2/E3a/E3b/E4 destrava completar a frente (Modes B/C + Modal + Reconciliação P2/P3 + Saved Comparisons).
Arquivos novos em src/core/icr/ui/:
compareCodersTypes.ts— types de estado central (CompareCodersViewState,CurrentSelection,ComparisonScope,ComparisonFilters)unifiedCompareCodersView.ts—ItemViewshell. Constructor(leaf, plugin)per project pattern. Toolbar sticky com 2 mode pickers + estado central. Render delega pra módulosoverviewMatrix.ts— Mode A matriz coder×coder. Cohen κ pareado por célula (viareportPairwiseno reporter). Color scale fixo (vermelho<0.4, laranja<0.6, verde claro<0.8, verde escuro)scopeExtraction.ts— cohort-level adapter. Itera 5 engines (md/pdf/csvSegment/csvRow/audio/video), filter por scope, chama per-marker extractors dos slices 1+4 (extractMarkdownRangeetc), produzEngineKappaInput[].vault.cachedReadpra source text de markdown. Bbox engines (pdfShape,image) pulados — per-pair pathway do slice 6 fica pra E2drilldownSpatial.ts— P1 spatial. Lanes per coder com[ code-label ]colorido pra text-likes. csv-row delega pracsvCodingView.setCompareMode(cellStyle real no AG Grid)compareModeColoring.ts— helpers puroscomputeRowGradient(applications)(gradient CSS N stripes por coder) +computeRowMarkersByCell(markers)(Map<sourceRowId::column, markers[]>)filterChips.ts— toggle coders (modificafilters.visibleCoderIds) + "destacar conflitos" + "esconder agreement total"
Helper novo no reporter: reportPairwise(inputs, pairs) — KappaReport por par. Cohen κ direto de aggregate.cohenKappa[a|b]; Fleiss/α/cu-α/α-binary via input filtrado ao par (filter markers por coderId, troca coders por [a, b]).
Hook novo em csvCodingView.ts: setCompareMode({ markerIndex, coderColors }) / clearCompareMode(). cellStyle callback consulta compareModeContext e retorna { background: linear-gradient(...) } quando há row markers no escopo.
Comando palette: Compare Coders: Open (view type qc-compare-coders).
Testes: 43 novos (3032 → 3075 total), distribuídos em tests/core/icr/reportPairwise.test.ts + tests/core/icr/ui/*.test.ts.
Segunda camada da Compare Coders View. Completa overview (Mode B tabela + Mode C heatmap), integra bbox engines via per-pair pathway, ativa coefficient picker funcional + filter "esconder agreement total", entrega Modal "ver lado a lado" + polish E1 (κ=0 vacuous).
Arquivos novos em src/core/icr/ui/:
coefficientResolver.ts—getCoefficientValue(report, coef, pair?)extrai número doKappaReport(Cohen pareado direto; Fleiss/α/α-binary/cu-α scalar) +isCoefficientApplicable(coef, N, engines)(Fleiss requer 3+ coders; α-binary/cu-α requerem engine com boundary)coefficientPicker.ts— render 5 chips no toolbar com disabled state (mesmo pattern dos mode chips)bboxScopeExtraction.ts—computeBboxKappaForPair(scope, pair, mode, theta). WrapbboxAdapter.buildKappaInputper-pair. Modes'unified'(1 KappaInput pdfShape ∪ image → coluna virtual'spatial-bbox') ou'split'(2 separados)overviewSharedRender.ts—kappaClass(k)+ thresholds extraídos deoverviewMatrix(reuso entre 3 modes)overviewTable.ts— Mode B. 1 row por código com markers no escopo × 5 coeficientes. Sort default por pior coeficiente primário (Cohen pra N=2; Fleiss pra N≥3) ascendente. Click row →currentSelection: { kind: 'code' }overviewHeatmap.ts— Mode C. Linhas codes × colunas engines visíveis + spatial-bbox (default unified) ou pdfShape | image (toggle splitBboxEngines). Cell = primaryCoefficient. Cinza n/a quando code não aparece na engine. Bbox: avg de C(N,2) Cohen κ pareados pra N>2coderInclusion.ts—getCodersWithMarkersInScope(scope, models)+applyCoderInclusion(scope, models, includeWithoutMarkers). Polish E1: filtra coders com 0 markers no escopo (default off; toggle reincluí). Considera todos engines incluindo bboxnarrativeDiagnostic.ts—analyzeDiagnostic({ cohen, alphaBinary, cuAlpha })puro. 3 padrões hardcoded: cohen baixo + α-binary alto (boundary OK / código diverge) / cohen baixo + α-binary baixo (boundary disagreement) / cu-α << κ gap ≥ 0.4 (code-within-boundary)compareCoderCoefficientsModal.ts—extends Modal. 2 estados toggle no header (single-pair adiciona breakdown per-engine; all-pairs lista C(N,2) aggregates). Diagnóstico narrativo dispara em single-pair quando padrão bate. Export markdown via clipboard com Notice de confirmação. Field renomeadocompareScope(nãoscope) pra evitar colisão comModal.scopeda API Obsidian
Modificações:
overviewMatrix.ts— lêstate.primaryCoefficientviagetCoefficientValue(não mais Cohen hardcoded). Bbox entra noreportPairwisevia paramperPairInputs?: Map<pairKey, EngineKappaInput[]>(Slice E5b-followup, 2026-05-11) — aggregate weighted pormarkers.lengthnatural, eliminou avg 50/50. AplicaapplyCoderInclusion+hideAgreementTotalfadefilterChips.ts— adiciona 6 engine chips (markdown / pdf / csv-seg / csv-row / audio / video) + toggles "split bbox engines" + "incluir coders sem markers". Coder chip ganhais-empty(cinza claro + tooltip) quando coder não tem markers + filter polish offcompareCodersTypes.ts—ComparisonFiltersganhavisibleEngineIds?(override scope.engineIds via toggle),splitBboxEngines?(default false),includeCodersWithoutMarkers?(default false)scopeExtraction.ts:EngineModelsForExtraction— adicionaimage?: { getAllMarkers(): ImageMarker[] }+pdf.getAllShapes?(): PdfShapeMarker[]unifiedCompareCodersView.ts— toolbar ganha picker + botão↗ ver lado a lado.engineModels()incluiimageModel. Plug Modes B/Ccore/types.ts:GeneralSettings.showNarrativeDiagnosis?(default true) — opt-out via Settings tab
Setting nova: general.showNarrativeDiagnosis (default true). Toggle em Settings tab esconde caixa amarela do diagnóstico.
Bbox em matriz Mode A usa average 50/50 entre text-likes e bbox quando ambos contribuem pro pair. Weighting proper via #events fica em backlog (não bloqueia E2 — UX honesta com tooltip ainda em backlog também).
spatial-bbox NÃO é EngineId do reporter — é label de UI no heatmap. Reporter recebe engine: 'pdfShape' mesmo quando bbox unified, porque é só agregação visual.
Modal Obsidian gotcha: classe Modal tem propriedade scope: Scope própria. Subclasse não pode declarar campo scope novo (TS error). Workaround: usar nome diferente (compareScope).
Testes: ~75 novos (3075 → 3150 total), em tests/core/icr/ui/: coefficientResolver, coefficientPicker, bboxScopeExtraction, overviewTable, overviewHeatmap, coderInclusion, narrativeDiagnostic, compareCoderCoefficientsModal + extensões em overviewMatrix + filterChips.
Surface: ItemView único qc-icr-import (full tab via getLeaf('tab'), não sidebar). Layout grid 200px (rail) + 1fr (main com toolbar + body re-render). Reusa pattern qc-cc-mode-chip do Compare Coders.
3 chips no body:
- ▦ Visão geral — seções inline expandable (codebook divergence + sources problemáticos + OK) + footer Apply via
divergenceResolver.computeBreakdown - ▤ Lado a lado — marker-by-marker com nav ←/→ + filter chips (todos/sobrepondo/novos) + filterCodeId opcional. Markers locais sobrepondo via
findOverlappingLocalMarkers(extract*Range + computeOverlap). - ▥ Por código — agrupa por codeId com counts + batch actions (Accept all / Skip all / Revisar 1-a-1 → muda chip filtrado)
Motor estendido (P0 prereq): mergeCoderContribution(..., options?: { dryRun?, overrides? }). dryRun: true computa MergeResult sem mutar localData (preview pra UX). overrides: ResolutionOverrides aplica skip per-source/code/marker + manter local. Precedência §4.2: skipSource ⊃ skipCode ⊃ skipMarker ⊃ pending.
Bug latente fixado: motor agora emite source_not_found pra fileIds referenciados por markers que escaparam de payload.sources (caso real: extract sem hash registry pro PDF). Sem isso, UX mostraria "N ficam fora" sem seção pra explicar.
Triggers:
- Import = ribbon
git-pull-request("ICR Import") + comandoICR: Open import - Export = botão
↗ exportar contribuiçãono toolbar do Compare Coders View + comandoICR: Export my contribution. Filter coders portype === 'human'. Modal seleção quando >1. Salva emvault/icr-exports/<slug>-<iso>.jsonviavault.adapter.write.
Arquivos novos em src/core/icr/contributions/:
contributionViewTypes.ts— IcrImportViewState, PendingContribution, ResolutionOverrides + helpers (createEmpty, clone)contributionLoader.ts— parse PayloadV1 com erros estruturadosdivergenceResolver.ts— computeBreakdown puro (N_in/N_out)unifiedIcrImportView.ts— ItemView, drop handler, keyboard nav, applyContribution, recompute previews sequenciaisimportToolbar.ts— chips + sub-pergunta + meta headerrail.ts— lista lateral + drop zone (DOM puro testável)overviewChip.ts— 3 seções inline + footersideBySideChip.ts— marker card + filterbyCodeChip.ts— group + batch actionsoverlapHelper.ts— predicate per engine via extract*Range + computeOverlap (markdown degraded sem sourceText)exportTrigger.ts— orquestrador export + CoderPickerModal
Modificações: mergeCoderContribution.ts (options param), unifiedCompareCodersView.ts:91 (botão export), main.ts (registerView + ribbon + 2 commands + helper openIcrImportView), tests/setup.ts (setText polyfill).
Limites conhecidos (refinements no BACKLOG):
- Markdown overlap retorna [] sem sourceText (degraded mode — PDF + CSV funcionam)
- Por código overlap = aproximação
min(local, incoming)por codeId (não range overlap exato) - "Map manual" pra source não implementado (só Skip / Trust local)
Testes: 72 novos (3150 → 3222 total), em tests/core/icr/contributions/.
Reconciliação multi-coder via drill-down Cards em UnifiedCompareCodersView. Pesquisador escolhe região contestada → cards lado a lado mostram codes de cada coder → 4 ações (adopt additive / adopt overwrite / accept-divergence / split em code novo). Decisão fica em audit log reversível.
Schema audit (extensão em src/core/types.ts):
BaseAuditEntry.entity? ganha terceiro discriminator 'reconciliation'. BaseAuditEntry.codeId continua sendo anchor: target code da adopt, newCodeId da split, candidateCodeIds[0] da accept-divergence (ou '' se sem candidatos — entry vai pra queue P3 mas não polui timeline). Soft-delete via hidden funciona igual.
3 audit types novos:
| { entity: 'reconciliation'; type: 'reconciliation_opened';
region: { fileId; engine: EngineId; bounds: ReconciliationBounds };
coderIds: CoderId[]; candidateCodeIds: string[] }
| { entity: 'reconciliation'; type: 'reconciliation_decided';
region; coderIds; decision: ReconciliationDecision;
consensusMarkerId?: string; memoOfReconciliation: string }
| { entity: 'reconciliation'; type: 'reconciliation_reverted';
originalEntryId: string; restoredMarkerIds: string[] }ReconciliationDecision union: adopt{codeId, mode, preStateSnapshot?} / split{newCodeId, mode, preStateSnapshot?} / accept-divergence / reject. mode = 'consensus-marker' | 'overwrite-originals' (overwrite-only no adopt/split). ReconciliationBounds discriminado: text{from,to} (markdown char offsets) / csvRow{rowIndex, column?} / temporal{fromMs, toMs} (audio/vídeo Fase 2). MarkerSnapshot{markerId, engine, fileId, serialized: unknown} armazena round-trip JSON pra revert.
getEntriesForCode(log, codeId) foi estendido pra incluir entries entity='reconciliation' cujo anchor codeId bate — Code Stability Timeline existente exibe as decisões de reconciliação naturalmente. codebookTimelineEngine mapeia os 3 types pro bucket 'reconciliation' (cor #e07b3f, distinto de created/edited/etc).
Coder type 'consensus' (em src/core/icr/coderTypes.ts):
CoderKind = 'human' | 'llm' | 'consensus'. CoderRegistry.createConsensus(slug, displayName?) idempotente — 'consensus:default' default, slugs adicionais permitidos pra waves ('consensus:wave-1', 'consensus:final'). getCodableCoders() retorna filter excluindo consensus (peer ICR feature em backlog: dropdown coder picker em popovers das 5 engines).
IcrMarkerOps façade (em src/core/icr/markerOps.ts):
Interface cross-engine pra orquestrador. Métodos: createMarker(engine, spec) → {markerId}, removeMarker, updateMarker(fields: {codes?}), serializeMarker → MarkerSnapshot, restoreMarker(snapshot), findMarkersInRegion(region) → {markerId, codedBy, codes}[].
IcrMarkerOpsImpl (em src/core/icr/icrMarkerOpsImpl.ts) wrappando markdownModel (Marker.range line/ch) + csvModel.rowMarkers. PDF/csv-segment/audio/video/image/pdfShape lançam 'engine-not-supported-in-slice' (extensão exige bounds engine-specific — design em BACKLOG). Pra restore: insertMarkerRaw(marker) novo em codeMarkerModel + csvCodingModel re-insere marker já-formado + emite ADD event + salva.
Função orquestradora (em src/core/icr/reconciliation.ts):
executeReconciliationDecision(params): ReconciliationResult. Pipeline:
- accept-divergence / reject: append audit, retorna (audit-only)
- adopt: ensure consensus coder via
coderRegistry.createConsensus→ validadecision.codeIdem registry → semode === 'overwrite-originals':findMarkersInRegion→ snapshot coders ∈region.coderIdsque NÃO têm targetCode →updateMarkersubstituindo codes → cria consensus marker viacreateMarkercodedBy'consensus:default'→ append audit - split: cria
CodeDefinitionviaregistry.create(name)→ mesmo branch adopt mastargetCodeId = newCodeId
executeReconciliationRevert(originalEntryId, params): branch por decision.kind + mode:
- adopt|split / consensus-marker:
removeMarker(consensusMarkerId).restoredMarkerIds = [consensusMarkerId] - adopt|split / overwrite-originals: pra cada snapshot,
restoreMarker(snapshot)+ remove consensus marker. Code novo de split NÃO é deletado (pode ter sido reusado em outros markers) - accept-divergence / reject: nada nos markers, só audit
P2 UI (em src/core/icr/ui/drilldownCards.ts):
renderDrilldownCards é mode picker 'cards' do drill-down. Sem região selecionada renderiza picker de regiões contestadas; com state.currentSelection.kind === 'region' renderiza view (cards + memo + 4 ações).
collectContestedRegions agrupa markers por overlap:
- markdown:
clusterMarkdownMarkersitera por file, sort porrangeKey(line, ch) = line × 1_000_000 + ch, agrupa por intersecção transitiva. CapturamarkerRefs[]no cluster (não depende de editor aberto pra resolver — corrige bug do smoke 2026-05-11 onde findMarkersInRegion retornava [] sem editor) - csvRow: agrupa por
(fileId, sourceRowId, column)
Cada ContestedRegion ganha divergenceKind:
'code': 2+ codes distintos (dropdown vai ter 2+ candidates) — chip vermelho, border-left vermelho'boundary': mesmo code, bounds diferentes — chip laranja'existence': só 1 coder — chip cinza, opacidade reduzida
Ordenação do picker: unresolved code > boundary > existence > resolved. Resolved é detectado via findLatestActiveDecision(region, log) — varre audit por reconciliation_decided matching regionKey(fileId, engine, bounds) E NÃO matched por reconciliation_reverted posterior. Resolvidas ganham chip ✓ RESOLVIDA + summary decisão: adopt/split/manter divergência, opacidade 0.65 (hover restaura 1).
SplitNewCodeModal (em src/core/icr/ui/splitNewCodeModal.ts): nome obrigatório + cor opcional + Enter submit + detecta nome duplicado (confirm pra reusar code existente).
Perf — fixes do smoke 2026-05-11:
renderOverview é async (vault.cachedRead per md + reportPairwise full). Clicks rápidos disparavam N renders paralelos competindo pela UI thread. Fixes em camadas:
- Serialize via Promise chain (
renderQueue): cada render espera o anterior. Token-guard ANTES do trabalho async descarta tokens stale sem pagar custo. - Cache module-level em
extractInputsFromScope(emsrc/core/icr/ui/scopeExtraction.ts): scope-hash key normalizada (arrays sorted pra estabilidade) +cacheGenerationcounter + LRU 50 entries.bumpInputsCacheGeneration()exposto pra invalidação manual. setSelectionskipa renderToolbar + renderOverview: descoberta crítica — overview NÃO destaca célula selecionada via state (matriz só usaonclick). Selection-only change só precisa re-renderizar drilldown. Reduz latência de click de 50-150ms+ pra <10ms.- Consensus fora do scope default: construtor usa
coderRegistry.getCodableCoders(), nãogetAll()— matriz fica 2×2 até user reincluir consensus via filter chip. - Render num scratch fragment + commit no container só se token ainda é o atual.
bumpInputsCacheGeneration() é chamado no construtor da view + no onAfterReconciliation (callback do drilldownCards). Test pollution evitada via beforeEach global em tests/setup.ts.
Arquivos novos:
src/core/icr/markerOps.ts— interface IcrMarkerOpssrc/core/icr/icrMarkerOpsImpl.ts— impl wrappando markdownModel + csvModelsrc/core/icr/reconciliation.ts— executeReconciliationDecision + executeReconciliationRevertsrc/core/icr/ui/drilldownCards.ts— P2 picker + region view + ações + classifyDivergence + resolution trackingsrc/core/icr/ui/splitNewCodeModal.ts— modal nome + cor
Modificações:
src/core/types.ts— 3 audit types + ReconciliationBounds + MarkerSnapshot + ReconciliationDecision +entitydiscriminatorsrc/core/auditLog.ts—renderEntryMarkdownestende com formatBoundsShort;getEntriesForCodeinclui entity='reconciliation' cujo codeId batesrc/core/detailCodeRenderer.ts+src/analytics/views/modes/codebookTimelineMode.ts— switch handlers pra reconciliation_*src/analytics/data/codebookTimelineEngine.ts— bucket'reconciliation'+ cor #e07b3f + mapping dos 3 typessrc/core/icr/coderTypes.ts—CoderKindaliassrc/core/icr/coderRegistry.ts—createConsensus+getCodableCoderssrc/markdown/models/codeMarkerModel.ts+src/csv/csvCodingModel.ts—insertMarkerRawpra restoresrc/core/icr/ui/scopeExtraction.ts— cache module-level + bumpInputsCacheGenerationsrc/core/icr/ui/unifiedCompareCodersView.ts— drill-down mode picker + renderDrilldown switch + renderToken + renderQueue + setSelection skipsrc/main.ts—icrMarkerOps?: IcrMarkerOpsfield + instanciação após models loaded + smoke runtime hook (__icrSmoke.executeReconciliationDecision/Revert)styles.css— ~300 linhas (qc-cc-region-, qc-cc-card, qc-cc-action*, qc-cc-divergence-tag, qc-cc-split-*)tests/setup.ts—beforeEachglobal prabumpInputsCacheGeneration(evita test pollution)
Limites conhecidos (refinements no BACKLOG §ICR Slice E3a):
- IcrMarkerOps cobre só markdown + csvRow (Fase 1 do E3a). Extensão pra pdf-text + csv-segment + audio + video + image + pdfShape exige variants engine-specific em ReconciliationBounds.
- Coder picker em coding ativo não existe (peer ICR feature). Bloqueio em popovers é trivial pq não há UI exposta pra escolher consensus.
- Workflow queue P3 (Slice E3b) entrega revert via UI + κ pré/pós toggle + export relatório markdown.
Testes: +81 (3222 → 3303 total), distribuídos em tests/core/auditLogReconciliation.test.ts (16), tests/core/icr/reconciliation.test.ts (20), tests/core/icr/icrMarkerOpsImpl.test.ts (13), tests/core/icr/drilldownCards.test.ts (22), tests/core/icr/coderRegistry.test.ts (+10 cases consensus).
Slice E3b fecha o drill-down (P1 espacial + P2 cards + P3 workflow queue) e adiciona o ciclo audit completo: marcar pra revisão · decidir · reverter · exportar.
Módulos novos:
src/core/icr/ui/regionDerivation.ts— extração das helpers puras de detecção/categorização que estavam emdrilldownCards.ts. Inclui:ContestedRegion,DivergenceKind,RegionStatus,RegionsByStatuscollectContestedRegions(state, engineModels)cluster markdown + csvRowregionKey/sameBounds(chaves estáveis pra match com audit log)findLatestActiveDecision(region, log)— última decided sem revert posteriorfindLatestActiveOpenedEntry(region, log)— último opened (pra "Em discussão")getRegionStatus(region, log)— decisão → opened → fallback opencategorizeRegionsByStatus(regions, log)— distribuição nas 4 colunas
src/core/icr/ui/drilldownWorkflow.ts—renderDrilldownWorkflow(P3) com header (totals + botão Exportar) + 4 colunas Abertos/Em discussão/Resolvidos/Divergência aceita + cards com action Abrir (vai pra P2) + action Reverter em Resolvidos/Divergência aceitasrc/core/icr/ui/reconciliationReport.ts—generateReconciliationReportpuro (timeline + memos + κ pré/pós + secções por status)openReconciliationemsrc/core/icr/reconciliation.ts— emitereconciliation_openedsem aplicar mudanças
Reporter — flag excludeConsensusCoders via scope filter:
Reporter continua puro sobre EngineKappaInput[]; filtro de coders consensus acontece antes via applyConsensusExclusion(scope, coderRegistry, exclude) (novo helper em coderInclusion.ts). Wired nos 3 overview modes (matriz/tabela/heatmap) e no modal "ver lado a lado". Chip toolbar "excluir consensus (κ pré)" aparece só quando getConsensusCoderIdsInScope(scope, coderRegistry).length > 0.
Default scope mudou: view antes inicializava state.scope.coderIds = getCodableCoders().map(c => c.id) (excluía consensus). Agora state.scope.coderIds = getAll().map(c => c.id) (inclui consensus); applyCoderInclusion (default sem markers off) remove consensus quando ele não tem markers. Pós-reconciliação consensus aparece naturalmente; chip "excluir consensus" filtra UI quando user quer ver κ baseline humano-humano.
Modal pré/pós: CompareCoderCoefficientsModal ganhou estado interno prePost: 'pre' | 'post' (default 'post'). Toggle no header só aparece quando há consensus no scope. Banner indicativo em todas tabelas. Em single-pair com par envolvendo consensus E prePost === 'pre', empty state "Par envolve consensus — alterne pra 'pós'". exportMarkdown indica a visão no header.
Audit-only path: "Em discussão" (via botão "Marcar pra revisão" no P2) emite reconciliation_opened sem tocar markers. Decisão posterior (decided) supera o opened naturalmente via ordenação cronológica em getRegionStatus. Revert via UI no card P3 chama executeReconciliationRevert existente; card volta pra Abertos (status recomputado live via categorizeRegionsByStatus).
Limitações herdadas (no Slice E3b):
- IcrMarkerOps continua markdown + csvRow (Fase 1 do E3a). Extensão pendente em backlog.
- Workflow queue não tem virtualização ainda; spec §4.3 deixou nota pra reabrir se ficar lento em vault grande com muitas regiões abertas.
Testes: +62 (3303 → 3365 total), distribuídos em tests/core/icr/ui/regionDerivation.test.ts (23), tests/core/icr/openReconciliation.test.ts (5), tests/core/icr/ui/drilldownWorkflow.test.ts (8), tests/core/icr/ui/reconciliationReport.test.ts (9), tests/core/icr/ui/coderInclusion.test.ts (+9 cases), tests/core/icr/ui/compareCoderCoefficientsModal.test.ts (+6 cases pré/pós), tests/core/icr/icrMarkerOpsRangeKey.test.ts (4 regression do bug rangeKey).
Bug crítico fixado durante smoke 2026-05-11 — rangeKey vazando como char offset: regionDerivation.buildMarkdownRegionFromCluster encoda bounds como rangeKey = line × 1_000_000 + ch (chave artificial pra clustering ordinal). Em E3a esse valor era passado direto pra markerOps.createMarker(engine='markdown', { bounds }) que setava range: { from: { line: 0, ch: <rangeKey> } } — range.ch virava 10_000_000. Invisible em E3a porque scope default excluía consensus → extractInputsFromScope filtrava esses markers fora. E3b mudou scope default pra incluir consensus → explodeMarkersToCharLabels iterava 2M chars por marker × 9 consensus markers = main thread travada por 60s+. Fix em icrMarkerOpsImpl.ts: decodeRangeKey() e rangesOverlapLineCh() (linha-comparison via rangeKey). Limpeza one-shot do data.json removeu 9 consensus markers corruptos + 17 audit entries órfãs.
Entrega: "guardar configurações de view nomeadas + voltar nelas + atalho contextual do codebook".
Schema (em src/core/types.ts + src/core/icr/ui/compareCodersTypes.ts):
interface SavedComparison {
id: string; // sc_cmp_*
name: string;
scope: ComparisonScope;
view: { overviewMode; drilldownMode; primaryCoefficient };
filters: ComparisonFilters;
createdAt: number;
updatedAt: number;
}
QualiaData {
comparisons?: { definitions: Record<string, SavedComparison>; order: string[] };
lastCompareCodersUsed?: { scope; view; filters }; // ephemeral fallback
}comparisons espelha smartCodes (sem palette — saved não têm cor visível). lastCompareCodersUsed é o fallback ephemeral persistido quando view fecha sem estar vinculada a saved.
Registry (src/core/icr/comparisonRegistry.ts): mesmo pattern de SmartCodeRegistry — state mutado in-place, addOnMutate(fn), toJSON/fromJSON. Sem audit listener — saved comparisons são preferência de UX, não decisão analítica. CRUD: create/rename/update/delete/duplicate. Clones defensivos em create/update evitam mutações externas vazarem (testes verificam).
Dirty detection (src/core/icr/ui/compareCodersDirty.ts — puro):
computeDirty(state, saved)—truequando scope/view/filters divergem- Arrays comparados como sets (reordenação não conta como dirty)
undefined≠[]em opcionais (undefined= "todos",[]= "nenhum")- Booleans optional default
false(splitBboxEngines ?? false) - Ignora
currentSelection/loadedFromSavedId/isDirty(ephemeral)
View wiring (unifiedCompareCodersView.ts):
- Constructor: carrega
lastCompareCodersUsedse existe; senãocreateDefaultViewState loadFromSaved(id)— copia scope/view/filters do saved, settaloadedFromSavedId,isDirty = false, bump cacheloadContextualCode(codeId)— atalho contextual: scope filtrado + Tabela mode, semloadedFromSavedIdupdateStatechamarefreshDirtyFlag()antes do re-render. Se saved sumiu, desvincula automaticamenteonClosepersistelastCompareCodersUsedsó quando state é ephemeral- Banner no toolbar quando
loadedFromSavedIdpresente:●dirty +Salvar mudanças(só dirty) /Salvar como nova/✕ desvincular
Hub modal (compareComparisonsListModal.ts) — espelha SmartCodeListModal simplificado (sem detail interno): cards com nome bold + summary do escopo + timestamp humanizado + kebab Open/Rename/Duplicate/Delete. Click card → fecha modal + abre view via openCompareCodersView(plugin, { loadFromSavedId: id }).
Create modal (createComparisonModal.ts) — minimalista: só nome + Create. Defaults via createDefaultViewState quando sem initialState. Aceita initialState opcional usado pelo "Salvar como nova" da view (captura state atual).
Helper centralizado (openCompareCodersView.ts): aceita { loadFromSavedId? | contextualCodeId? }. Reusa leaf existente (getLeavesOfType) ou cria nova; após setViewState, chama loadFromSaved ou loadContextualCode conforme.
Entry points:
- Ribbon
users-2(lucide) — click →openCompareCodersView(plugin)(state ephemeral) - Command palette:
Compare Coders: Open+Open hub+New comparison - Codebook context menu: novo item
Ver κ deste código entre coders(iconusers-2) — só aparece quandoContextMenuCallbacks.openCompareForCodeé injetado (opcional pra não quebrar callers sem ICR). Wired emBaseCodeDetailView.contextMenuCallbacks()
Testes: +27 (3365 → 3392 total), distribuídos em tests/core/icr/comparisonRegistry.test.ts (15: CRUD + clone defensivo + roundtrip JSON + unsubscribe) e tests/core/icr/ui/compareCodersDirty.test.ts (12: equalSavable + set semantics + undefined vs []).
Entrega: todo marker criado via UI carrega codedBy populado — fluxo end-to-end ICR não depende mais de seed scripts pra povoar a identidade do coder.
Schema: data.activeCoderId?: CoderId em QualiaData. Optional pra round-trip de data antigo. Fallback gracioso em plugin.getActiveCoderId(): undefined → DEFAULT_CODER_ID; coder deletado fora → também cai pro default.
Plugin API (em main.ts):
getActiveCoderId(): CoderId— fallbackDEFAULT_CODER_IDquando undefined ou id inválidosetActiveCoderId(id: CoderId)— valida viacoderRegistry.has(id)+ emitactiveCoderListenersonActiveCoderChange(fn)— retorna unsubscribe
Status bar UI (src/core/icr/activeCoderStatusBar.ts):
mountActiveCoderStatusBar(plugin)— chamado noonload, retorna{ unmount }registrado emcleanups- Chip "Coding as: {nome}" com ícone
userno status bar do Obsidian - Click →
MenucomgetCodableCoders()(exclui consensus) + "+ Novo coder humano" (PromptModal →createHuman+setActiveCoderId) - Re-renderiza em
coderRegistry.addOnMutate(coder novo) +onActiveCoderChange(troca selecionada)
Engine wire — 4 engine models migrados pra receber plugin no constructor (em vez de só dm):
PdfCodingModel:constructor(plugin, registry)em vez de(dm, registry)CsvCodingModel: idemMediaCodingModel: idem (AudioCodingModel + VideoCodingModel herdam)- Markdown já recebia plugin
Cada call site de criação de marker stampa codedBy: this.plugin.getActiveCoderId():
codeMarkerModel.ts:97(findOrCreateMarkerAtSelection) +:119(fallback sem editor)pdfCodingModel.ts:177(findOrCreateMarker text) +:303(createShape)csvCodingModel.ts:106(setCellComment auto-create),:246(findOrCreateRowMarker),:285(addCodeToManyRows bulk),:411(findOrCreateSegmentMarker)mediaCodingModel.ts:174(findOrCreateMarker — audio + video compartilhado)
Fix paralelo (Compare Coders contextual): loadContextualCode agora marca contextualMode = true na view; onClose skipa lastCompareCodersUsed persist quando true. Antes: atalho contextual ("Ver κ deste código entre coders") deixava lastCompareCodersUsed com scope filtrado em 1 código + Tabela mode; reload sem banner deixava view presa sem maneira óbvia de voltar ao default.
Tests: ~+15 test files de engine models tinham new XCodingModel(dm as any, ...) — refatorados pra { dataManager: dm, getActiveCoderId: () => 'human:default' } as any shape mínimo de plugin. Total 3392 verde.
Bug registrado em BACKLOG (não fixado nesta entrega): CSV row markers são shared cross-coder por cell — findOrCreateRowMarker(file, rowId, column) retorna marker existente independente do coder ativo. Quando coder B troca picker e aplica código já aplicado pelo coder A, o no-op é silencioso. Use case "duas pessoas no mesmo PC trocando perfil" não é cenário real do projeto, mas a semântica merece decisão (1 marker per cell+coder vs codedByList[]). Pensar conjuntamente com como ICR semântica trata row-level vs segment-level coding.
Entrega: reconciliação P2 Cards funciona em pdf-text, csv-segment, audio, video — antes só markdown + csvRow eram suportados (outras engines davam "engine-not-supported-in-slice" no drill-down).
Schema — ReconciliationBounds ganhou 2 variants:
export type ReconciliationBounds =
| { kind: 'text'; from: number; to: number } // markdown
| { kind: 'csvRow'; rowIndex: number; column?: string } // CSV row marker
| { kind: 'csvSegment'; rowIndex: number; column: string; from: number; to: number } // novo E5a
| { kind: 'pdfText'; page: number; from: number; to: number } // novo E5a
| { kind: 'temporal'; fromMs: number; toMs: number }; // audio + videoBbox spatial (image + pdfShape) fica pro Slice E5b — semantics 2D não trivial (adopt = union ou intersect?).
Switches sincronizados (todos cobrindo 5 kinds):
formatBoundsLabelemregionDerivation.ts(display) eformatBoundsShortemauditLog.ts(timeline)isValidBounds+unionOfBoundsemreconciliation.ts(consensus bounds derivation)sameBoundsemregionDerivation.tsesameBoundsLocalemreconciliationReport.tsregionKey(chave de deduplicação cross-region)
Collectors novos em regionDerivation.ts:
collectPdfTextRegions(pdfModel, scopeCoders): agrupa markers por(fileId, page)→ cluster por overlap embeginIndex/endIndex→ emitContestedRegionse ≥2 coders no cluster.collectCsvSegmentRegions(csvModel, scopeCoders): agrupa por(fileId, rowIndex, column)→ cluster por overlap emfrom/to→ emit. Detecta segment via presença defromnumérico (RowMarker não tem).collectTemporalRegions(mediaModel, scopeCoders, engine: 'audio' | 'video'): agrupa porfileId→ cluster por overlap emfromMs/toMs→ emit com engine recebido como param.formatMshelper local (MM:SSdisplay).
collectContestedRegions agora itera 5 engines (antes 2). Wire dos novos collectors verificou engineModels.{pdf,audio,video} antes de chamar.
IcrMarkerOpsImpl refactor — antes tinha branches per-engine duplicados; agora:
getModelForUpdate(engine)retorna interface mínima{ findMarker, addCodeToMarker, removeCodeFromMarker }— extrai pattern compartilhado. Markdown/Csv/Pdf/Audio/Video plugam o mesmo update flow.createMarker/removeMarker/restoreMarker/findMarkersInRegionganharam branches pros 4 engines novos. Snapshot/restore viainsertMarkerRawem cada model.- PDF text marker criado via ICR usa
beginIndex/endIndex= bounds.from/to;beginOffset/endOffset = 0etext = ''(consensus markers não têm anchor span-relative — collector trabalha em coords range-level).
Métodos novos nos engine models:
PdfCodingModel.insertMarkerRaw(marker): push + notify + emit ADD eventMediaCodingModel.insertMarkerRaw(marker):getOrCreateFile+ push + notify + emit (CsvCodingModel já tinha; markdown idem)
Provenance audit (attachSourceHashSnapshot) — wire mecânico em todas paths de criação singular:
pdfCodingModel.findOrCreateMarker(text) +createShapecsvCodingModel.setCellComment(auto-create row) +findOrCreateRowMarker+findOrCreateSegmentMarkermediaCodingModel.findOrCreateMarker(audio + video compartilhado)- Bulk
addCodeToManyRowspropositalmente fora — N hash requests por batch seria custoso, vale só pra criação singular onde latência humana esconde a I/O.
Seed (scripts/seed-icr-corpus.mjs) — estendido pra cobrir audio + video. Coders agora aditivos (ensureCoder(id, name) em vez de substituir o array) — preserva coders criados via UI fora do seed.
Testes — 3392 → 3414 (+22):
tests/core/icr/ui/regionDerivation.collectors.test.ts: 14 cases pros 3 collectors novos (overlap, group boundaries, scope filtering, divergenceKind classification)tests/core/icr/icrMarkerOpsImpl.test.ts: 8 cases pros 4 engines novos (create + reject bounds + findMarkersInRegion). Tests antigos de "engine-not-supported" reescritos pra cobrir só pdfShape + image (E5b ainda pendente).
Lentidão observada no smoke (não bloqueante): registrada no BACKLOG. Hipótese: 3 collectors a mais por chamada de collectContestedRegions (sem cache). Pra escala do seed (~30 markers cross-engine) deve ser sub-ms, mas no dev box pareceu perceptível. Verificar se memoização (chave = state.scope hash + generation counter) vale, ou se é perf de hardware.
Problema: Compare Coders View travava main thread 400-1900ms em "primeira passada" de combinação de filter chip (toggle de engines no toolbar) porque 5 coeficientes ICR (cohen/fleiss/alpha/alphaBinary/cuAlpha) rodavam síncronos sobre per-char positions × per-coder. Pra 4 coders × 5 engines = 6 pairs × 25 coef-instances = ~150 ops de explodeMarkersToCharLabels, cada uma iterando milhares de char keys.
Diagnóstico via instrumentation temporária no updateState (mediu toolbar vs overview por gesto): toolbar=1-5ms ✓, overview spike de 400-1900ms confirmou que o gargalo era compute pesado dos coefs, não render DOM.
Solução em 7 camadas (commits c1e8f8c → bbc6fca):
getCodersWithMarkersInScopecache (src/core/icr/ui/coderInclusion.ts) — itera markers de 7 engines em todorenderToolbar; LRU 50 por scope-hash + gen counter, iteração inline sem spread.collectContestedRegionscache (src/core/icr/ui/regionDerivation.ts) — itera 5 engines + clustering em todo drilldown; mesma estratégia LRU 50.reportKappa/reportPairwise2 camadas (src/core/icr/reporter.ts) — fast path WeakMap por identidade do array de inputs (troca de coefficient mantém ref), slow path Map porcacheKeyexplícita (filter chip cria array novo mas mesma chave de scope).extractInputsFromScopecache per-engine (src/core/icr/ui/scopeExtraction.ts) — toggle de chip não invalida outros engines; cada engine só recalcula 1× por scope (ignorando engineIds).- Heatmap + Tabela
Promise.all— antes 15 codes × 5 engines = 75awaitsequenciais; agora todos disparam em paralelo (cache hits resolvem juntos). explodeMarkersToCharLabelsmemo por identity (src/core/icr/kappaInput.ts) — 5 coefs dentro de cadacomputeAll(input)recebem o mesmoinput.markers; só a primeira explosão paga o custo, as 4 seguintes são hit.- Web Worker (
src/core/icr/kappa.worker.ts+kappaWorkerClient.ts) — compute dos 5 coefs roda off-main-thread; UI nunca bloqueia mesmo em combos não-cacheadas.
Invalidação unificada — bumpAllIcrCaches() em unifiedCompareCodersView.ts invoca os 4 bumps (bumpInputsCacheGeneration, bumpCoderInclusionCacheGeneration, bumpRegionsCacheGeneration, bumpReportCache) quando markers mudam (mutações de reconciliação, load saved, etc).
Resultado medido:
| Gesto | Antes | Depois |
|---|---|---|
| Troca de coefficient (Cohen↔Fleiss) | 1200ms | 1-3ms (cache hit) |
| Toggle de coder | 1200ms | 1-3ms |
| Toggle de chip engine (combo já vista) | 1200ms | 1-3ms |
| Toggle de chip engine (combo nova) | 1200ms (freeze) | UI fluida (Worker em background) |
Web Worker — detalhes arquiteturais:
src/core/icr/kappa.worker.ts— código standalone que importa só os 5 coefs puros (./coefficients/*) + tipos. Zero deps de runtime Obsidian. ListenpostMessagecom{ id, op: 'reportKappa' | 'reportPairwise', inputs, pairs? }→ devolve{ id, ok, result }ou{ id, ok: false, error }. Re-implementacomputeAll+aggregateReports+filterKappaInputToPair(cópia literal de reporter.ts) — manutenção: se mudar lógica em reporter.ts, propagar aqui.- esbuild plugin
inline-worker(esbuild.config.mjs) — imports com sufixo?inline(ex:import src from './kappa.worker.ts?inline') disparam build standalone do arquivo (format: 'iife',bundle: true,write: false) e injetam o JS resultante comoexport default ${JSON.stringify(source)}. Pattern espelha oduckdbWorkerInlinePluginque já existia pro DuckDB. Restrição Community Plugins Obsidian: entrega sómain.js + manifest.json + styles.css— worker tem que viver dentro de main.js. src/core/icr/kappaWorkerClient.ts— Blob URL + Worker singleton (lazy, criado no primeiro uso), promise-based viaMap<id, {resolve, reject}>. Auto-reset emworker.error(reconnect transparente).disposeKappaWorker()noonunloaddo plugin.src/core/icr/kappaSyncFallback.ts— fallback síncrono quandoWorker === undefined(jsdom em tests). ReusareportKappa/reportPairwisesync de reporter.ts. Detecção viatypeof Worker !== 'undefined'no client.- Async wrappers no reporter —
reportKappaAsync(inputs, cacheKey?)ereportPairwiseAsync(inputs, pairs, cacheKey?)checam caches main-thread primeiro (WeakMap + Map), deferem ao worker em miss, populam ambos caches no resolve. Callers (Matrix/Tabela/Heatmap) migraram pra essas versões async.
TypeScript ?inline support: src/inline-worker.d.ts declara declare module '*?inline' { const source: string; export default source; }.
Lição aprendida: band-aids empilhados (caches superficiais) mascaram lentidão estrutural mas não resolvem. Quando o problema é compute síncrono pesado bloqueando main thread, mover pra worker é a única solução real — não vale empurrar pra backlog. Documentado em memory/feedback_no_bandaid_avoidance.md.
Entrega: reconciliação P2 Cards/Workflow funciona em pdfShape + image — antes só E5a (markdown/pdf-text/csvRow/csvSegment/audio/video) era suportado. Slice E5 fecha completo com 8/8 engines do plugin.
Schema — ReconciliationBounds ganhou variant bbox:
| { kind: 'bbox'; page?: number; x: number; y: number; w: number; h: number };AABB normalizado 0–1 (mesma origem do motor κ bbox). page presente em pdfShape, ausente em image. Sempre representa retângulo axis-aligned mesmo quando shapes originais são ellipse/polygon.
Decisões de design (D1–D6):
- D1 — Consensus shape em 2D = AABB-union rect. Casa com 1D
unionOfBounds(min-max). Intersect rejeitado: pode degenerar pra ≈vazio quando IoU=θ. Polygon hull rejeitado: overkill (complica representação, revert, render). Override viaconsensusBoundsreservado pra UI custom futura. - D2 — Variant
bboxarmazena AABB plano, não coords completos. Bounds é da REGIÃO contestada, não das shapes originais — originais ficam emmarkerRefs[]apontando pros markers individuais (que preservam tipo rect/ellipse/polygon). - D3 — Cluster θ no collector = motor θ (0.5 COCO). Knob único evita semântica divergente entre matching (κ) e clustering (collector). Setting separado rejeitado: dual-knob confuso.
- D4 — Algoritmo cluster N coders = union-find no grafo IoU≥θ. Hungarian é pairing ótimo 1:1 entre 2 coders, não generaliza pra N>2. Aqui queremos componentes conexas — union-find é semanticamente correto + O(α(N)) por edge.
- D5 — Scope (engine, fileId, page?) separado. Markers em pages diferentes do mesmo PDF, ou em imagens diferentes, nunca clusterizam. Mesma regra do bboxAdapter (
scopeOf). - D6 — PercentShapeCoords vs NormalizedCoords (inconsistência herdada do image engine não refatorada nesse slice). Bounds AABB plano funciona pros 2 — convertido pra
PercentShapeCoords{type:'rect'}(pdfShape) ouNormalizedRect(image) no momento docreateMarker. Idêntica geometria, tipos diferentes.
Switches sincronizados (todos cobrindo 6 kinds):
isValidBounds+unionOfBoundsemreconciliation.ts— bbox union une apenas markers da mesma page (heurística defensiva).sameBounds+regionKey+formatBoundsLabelemregionDerivation.ts—bb:${page??'_'}:${x},${y},${w},${h}com 6 casas decimais pra evitar colisão.sameBoundsLocalemreconciliationReport.ts,formatBoundsShortemauditLog.ts.
Collector novo (collectBboxRegions em regionDerivation.ts):
- Itera
pdfModel.getAllShapes()+imageModel.getAllMarkers()filtrando por scope coders. - Agrupa por
(engine, fileId, page?)— markers em scopes diferentes nunca clusterizam. - Per scope:
aabbOflazy → AABB early-out (aabbOverlaps) → rasterize 1×/marker → IoU bitmap AND → union-find no grafo IoU≥0.5. - Adaptive grid size (200/400) inline — bboxes muito pequenas precisam grid maior (mesma heurística do
bboxAdapter.detectAdaptiveGridSize, replicada por escolha — evitar cross-module export). - Bounds emitido = AABB-union do cluster. DisplayLabel =
bbox NN%,NN% (NN×NN%)com page prefix opcional.
IcrMarkerOpsImpl refactor:
- 2 ramos novos em cada switch (
createMarker,removeMarker,findMarkersInRegion,restoreMarker,findMarkerRaw,getModelForUpdate). getModelForUpdate('pdfShape')adapta API distinta: PdfCodingModel usaaddCodeToShape/removeCodeFromShape/findShapeById(separado deaddCodeToMarkerque é só pra text markers).getModelForUpdate('image')usa API standard.findMarkersInRegion(bbox)usa AABB overlap puro (não IoU). Correto porque bounds é AABB-union do cluster; markers originais — que contribuíram pra union — todos batem por construção. AABB overlap é trivial + rápido.
Methods adicionados:
PdfCodingModel.insertShapeRaw(shape: PdfShapeMarker): push + notify + emit ADD event. EspelhainsertMarkerRawpra text markers.ImageCodingModel.insertMarkerRaw(marker: ImageMarker): mesma coisa.
Image engine wiring ao coder picker (gap pré-existente fechado nesse slice):
ImageCodingModel era a 8ª engine fora do coder picker — createMarker não stampava codedBy. Smoke E5b revelou: markers criados pela UI ficavam órfãos (codedBy: undefined) → collector filtrava todos fora.
- Constructor passou de
(dataManager, registry)pra(plugin, registry)—dataManager = plugin.dataManager, plugin armazenado. createMarkerstampacodedBy: this.plugin.getActiveCoderId().- Caller em
src/image/index.ts:registerImageEngineatualizado. - ~3 test instantiations atualizadas pro shape
{ dataManager: dm, getActiveCoderId: () => 'human:default' } as any(mesmo pattern dos 4 models já wired).
Tests — 3414 → 3432 (+18):
tests/core/icr/icrMarkerOpsImpl.test.ts: 2 tests "engine-not-supported" reescritos pra cobrir create/remove/findInRegion/serialize/restore + +11 cases pdfShape + image (incluindo polygon AABB overlap).tests/core/icr/ui/regionDerivation.collectors.test.ts: +7 cases collectBboxRegions (cluster IoU saudável, IoU<θ não clusteriza, page boundary, scope coder filter, polygon+rect mixing).
Perf observada: sub-100ms pra ~20 bboxes em scope típico (sem instrumentação dedicada). Raster cache cross-call deliberadamente NÃO implementado — regionsCache per-scope (existente) já cobre o caso comum (toggle de coder/coefficient sem mudança de markers).
Lição aprendida: smoke revelou gap de wiring (image coder picker) que era invisível pra typecheck + tests. Reforça a regra "Smoke real obrigatório a CADA chunk de implementação" — mocks não cobrem completude de wiring cross-engine. Documentado em CLAUDE.md §"Furos sistemáticos".
obsidian-qualia-coding/plugin-docs/research/ICR-MATERIA-2026-05-08.md— destilação da frente (atualizada 2026-05-09)obsidian-qualia-coding/plugin-docs/research/ICR-DESIGN-SKETCH-2026-05-08.md— esboço arquiteturalobsidian-qualia-coding/plugin-docs/research/ICR — Cenários cobertos e descobertos.md— cenários in-plugin vs workaroundobsidian-qualia-coding/plugin-docs/research/Deep Research Report - ICR Qualitative.md— pesquisa GPT 2026-05-09 (ATLAS.ti 25, NVivo 15, gaps multimodais)docs/ROADMAP.md §"Infra compartilhada"— checklist em slices
Este documento consolida decisões de:
docs/markdown/ARCHITECTURE.md— estudo arquitetural original (9 partes)docs/CROSS-ENGINE.md— análise comparativa cross-enginememory/engine-plugins.md— detalhes por enginememory/image-engine-briefing.md— briefing de porting do Imagememory/board-roadmap.md— Research Board roadmap + Fabric.js lessons- Análise comparativa de ferramentas QDA (ATLAS.ti, NVivo, MAXQDA, Dedoose, Taguette)