Trabalho Final · Modelagem e Simulação · UFSC

Autômatos Celulares Universais no GenESyS

A parte do Pedro: regras locais definidas pelo usuário — a Linha B do Tema 6, em que a regra é escrita em C++ e compilada em tempo de execução dentro do simulador.

DuplaPedro Henrique Gimenez
 João Vitor Curcio Sutter
DisciplinaINE5425 · Modelagem e Simulação
InstituiçãoUFSC
ProfessorRafael Cancian

O que foi construído: um novo tipo de regra local — USERDEFINED — para o componente de autômatos celulares do GenESyS. Em vez de escolher entre as poucas regras já codificadas no simulador, o usuário escreve uma única função C++, que é compilada para uma biblioteca dinâmica, carregada com dlopen e chamada em cada célula a cada passo. Tudo verificado bit a bit contra a teoria (Rule 30, Rule 90, Game of Life) por uma suíte de 22 testes.

01

O que é um autômato celular

Um autômato celular (AC) é um sistema dinâmico discreto no espaço e no tempo. Formalmente, ele é uma quádrupla (L, S, N, f) mais uma política de atualização. Cada uma dessas peças é configurável no componente do GenESyS.

L
Lattice
A malha de células. Pode ser 1D (linha), 2D (grade), 3D ou, em arquitetura aberta, N-D.
S
Conjunto de estados
Os valores que cada célula pode assumir. No caso clássico, S = {0, 1}.
N
Vizinhança
Quais células influenciam cada célula (ex.: esquerda+direita em 1D).
f
Regra local
A função que decide o próximo estado a partir do estado atual + vizinhos.

Política de atualização: síncrona em 2 fases

O motor do GenESyS (CellularAutomata_Classic) usa atualização síncrona. Isso é crucial e fácil de errar: todas as células enxergam o mesmo instante de tempo. O passo acontece em duas fases:

  1. Fase 1 — calcular. A regra f é aplicada a todas as células, sempre lendo o estado atual. O resultado vai para um campo "próximo estado" (setNextState), sem tocar no estado corrente.
  2. Fase 2 — trocar. Só depois que toda a malha foi calculada, faz-se a troca de uma vez (updateState): o "próximo" vira o "atual".
Por que duas fases importamSe você atualizasse célula por célula in place, uma célula já atualizada poluiria o cálculo da vizinha seguinte — o resultado dependeria da ordem de varredura. As duas fases garantem que o passo é determinístico e independente de ordem: é a definição matemática de atualização síncrona.

Vizinhanças

A vizinhança define a ordem canônica em que os vizinhos chegam à regra. No GenESyS, para 1D centrada raio 1, a ordem é exatamente [left, right] (a própria célula não entra na lista de vizinhos).

Center 1D · r=1 → [left, right]
von Neumann 2D · r=1 → 4 ortogonais
Moore 2D · r=1 → 8 vizinhos

célula central   vizinhos

Condições de contorno

O que acontece nas bordas da malha, onde faltam vizinhos? Cada estratégia dá um comportamento diferente:

Fixed (fixa)

As células fora da borda têm um valor constante (tipicamente 0). É o contorno usado em toda a verificação deste trabalho — simples e reprodutível.

Periódica / Closed

A borda direita "encosta" na esquerda (e topo no fundo em 2D). A malha vira um anel (1D) ou um toro (2D); nada se perde nas pontas.

Reflexiva

A borda é espelhada: o vizinho ausente assume o valor da própria célula refletida de volta para dentro.

Adiabática

O vizinho ausente copia o valor da célula da borda — como uma extrapolação de "gradiente zero" na fronteira.

02

Autômatos celulares elementares (Wolfram)

O caso mais simples e mais famoso: 1D, estados {0,1}, vizinhança [left, right]. A regra é totalmente descrita por um único número de 0 a 255 — a numeração de Wolfram.

Como o número da regra funciona

Cada célula olha para o trio (left, center, right). Há 8 configurações possíveis desse trio (de 111 até 000). A regra diz, para cada uma dessas 8 entradas, qual é o próximo bit. Esses 8 bits de saída, lidos em ordem, formam um número binário de 8 dígitos → daí 2⁸ = 256 regras possíveis.

# A "Rule 90" em binário é 01011010. Lendo as 8 vizinhanças do trio (left,center,right):
 trio:  111   110   101   100   011   010   001   000
 saída:  0     1     0     1     1     0     1     0     → 01011010₂ = 90

Internamente, a classe LocalRule_Elementary calcula o índice number = 4·left + 2·center + 1·right (left é o bit mais significativo) e lê o bit correspondente do número da regra. Esse é exatamente o cálculo de Wolfram.

Rule 30 caótico

Gera padrões pseudoaleatórios a partir de uma única célula. Foi usada como gerador de números aleatórios no Mathematica.

// próximo =
left ^ (center | right)

Rule 90 Sierpinski

A partir de um único 1, desenha o triângulo de Sierpinski — um fractal exato. É um XOR puro entre vizinhos.

// próximo =
left ^ right

Rule 110 universal

Provada Turing-completa: é capaz de computar qualquer coisa. O AC mais simples conhecido com esse poder.

// 8 bits =
01101110₂ = 110

Game of Life — B3/S23 (2D, Moore)

O AC bidimensional mais conhecido, de John Conway. Cada célula olha seus 8 vizinhos de Moore e conta quantos estão vivos:

Block still life

Um quadrado 2×2 — nunca muda. Cada célula viva tem exatamente 3 vizinhos vivos, então todas sobrevivem para sempre.

Blinker período 2

Uma linha de 3 células oscila entre horizontal e vertical, a cada passo. É o oscilador mais simples.

Glider spaceship

Um padrão de 5 células que se repete a cada 4 passos, deslocado uma casa na diagonal — anda pela grade.

03

A contribuição do Pedro — Linha B

O Tema 6 oferecia duas linhas para representar a regra local. A Linha A (do Sutter) estende o parser do GenESyS. A Linha B (do Pedro) faz algo mais poderoso: a regra é um trecho de código C++ que o próprio simulador compila enquanto roda e carrega como biblioteca dinâmica.

A ideia em uma fraseEm vez de "hardcodar" cada regra nova como uma classe C++ recompilando o simulador inteiro, o usuário escreve uma função, o GenESyS chama o compilador g++ em tempo de execução, e a função vira código nativo carregado no processo que já está rodando.

O contrato da regra do usuário

O usuário só precisa escrever esta função — sem conhecer nenhuma classe interna do GenESyS (nada de Cell, State, etc.):

extern "C" long nextState(long self, const long* neighbors, int numNeighbors);

O extern "C" é essencial: ele desliga o name mangling do C++, para que o símbolo se chame literalmente nextState e possa ser encontrado por dlsym.

O pipeline: do texto ao código nativo

1
Usuário escreve a regra
Um trecho C++ com a função nextState (ou só o corpo, via wrapBody).
2
CppCompiler compila
Gera um .cpp e chama o g++ para produzir uma biblioteca dinâmica.
g++ -shared -fPIC
3
Carrega a lib
O simulador abre a .so recém-criada no próprio processo.
dlopen()
4
Resolve o símbolo
Pega o ponteiro para a função nextState dentro da lib.
dlsym()
5
Chama por célula
A cada passo, para cada célula, o motor invoca a função e grava o próximo estado.
ruleFunction(...)
Por que isso é sólidoO desenho espelha o componente CppForG já existente no GenESyS (em plugins/components/ExternalIntegration/CppForG.cpp), que já fazia compile → load → dlsym com símbolos extern "C". Ou seja, a Linha B não inventa um mecanismo novo — reutiliza uma técnica já validada no próprio simulador.

Exemplos de regras (é só isso que o usuário escreve)

// Rule 90 — Sierpinski (XOR dos vizinhos)

extern "C" long nextState(long self, const long* neighbors, int n) {
    return neighbors[0] ^ neighbors[1];   // left XOR right
}

// Rule 30 — caótico (left XOR (center OR right))

extern "C" long nextState(long self, const long* neighbors, int n) {
    return neighbors[0] ^ (self | neighbors[1]);
}

// Game of Life — conta vizinhos vivos (Moore 2D, B3/S23)

extern "C" long nextState(long self, const long* neighbors, int n) {
    long living = 0;
    for (int i = 0; i < n; ++i) living += neighbors[i];
    if (self == 1) return (living == 2 || living == 3) ? 1 : 0;
    return (living == 3) ? 1 : 0;
}

O que foi implementado (código real no GenESyS)

A classe LocalRule_UserDefined

Header-only, herda de LocalRule. Recebe um CppCompiler injetado, tem build(userSource, error) que faz todo o pipeline (escreve fonte → compila → dlopen → dlsym), e applyRule(cell) que junta self+vizinhos num long[], chama a função e grava o próximo estado. O destrutor faz dlclose da lib. Inclui wrapBody() para o usuário escrever só o corpo da regra.

Integração no CellularAutomataComp

Novo tipo de regra USERDEFINED; método para passar a fonte do usuário; _buildUserDefinedRule() que monta o compilador e dispara o build; _check() reforçado com checagem semântica (rejeita USERDEFINED sem fonte ou com código inválido); e o caminho que conecta a regra ao kernel.

Persistência

_saveInstance / _loadInstance agora gravam e recarregam o tipo de AC, lattice, vizinhança, contorno, conjunto de estados, tipo de regra e a fonte da regra do usuário. A fonte é carregada antes do tipo de regra, para que um USERDEFINED já tenha seu código disponível ao ser reconstruído.

Correção de bug de heap + testes

Adicionado destrutor explícito ~CellularAutomataComp() que libera os sub-objetos (lattice, vizinhança, contorno, regra…) na ordem certa — antes vazavam. Mais a suíte gtest de 22 testes que prova tudo bit a bit.

04

Demos interativas

Brinque com os mesmos algoritmos que rodam no GenESyS. A lógica em JavaScript aqui é idêntica à do C++ — e produz os mesmos diagramas verificados nos testes.

a · Autômato elementar 1D — diagrama espaço-tempo (tempo para baixo)
Início: uma única célula 1 no centro · contorno fixed-0 · vizinhança [left, right]. A Rule 90 desenha o triângulo de Sierpinski; a Rule 30, caos.
b · Game of Life 2D — B3/S23, vizinhança de Moore
contorno toroidal (periódico)
0
Clique nas células para desenhar. Contorno toroidal: as bordas se conectam (o glider some de um lado e reaparece no outro).
c · Playground — escreva sua própria regra
espelha o contrato C++

Escreva o corpo de uma regra 1D usando self e neighbors (um array [left, right]). É exatamente o que o usuário escreve em C++ — só que aqui roda na hora, no navegador.

variáveis disponíveis: self, neighbors[0], neighbors[1]
Isto é a "carga didática": a função que você escreve aqui tem a mesma forma da função C++ que o GenESyS compila com g++ -shared -fPIC e carrega com dlopen.
05

Como sabemos que está correto

O Prof. Cancian separa verificação (o modelo está certo?) de validação (o modelo representa o sistema real?). Para um autômato celular, há uma virtude: o sistema é determinístico.

Verificação determinística ≠ análise estatísticaEm modelos estocásticos (filas, DES com aleatoriedade), correção exige replicações, intervalo de confiança e descarte de warm-up. Aqui nada disso se aplica: o AC não tem aleatoriedade. A mesma configuração sempre produz a mesma saída. Então a correção é a coisa mais forte possível — reprodução exata, bit a bit, da teoria conhecida (livro Cap. 11; ECA / Rule 30 / Rule 90 / Game of Life da literatura). Se um único bit divergir, o teste falha.
22/22 testes gtest passando — agrupados em 4 famílias. Cada um afirma uma verdade-base concreta da teoria.
Motor elementar (ECA) 4 testes
Rule30MatchesTextbookGroundTruth — reproduz o exemplo da p.242 do livro, linha a linha.
Rule90MatchesSierpinskiGroundTruth — desenha o triângulo de Sierpinski exato.
IsDeterministicAcrossRuns — rodar duas vezes dá saída byte a byte idêntica.
EmptyGridStaysEmptyUnderRule30 — malha toda 0 permanece 0 (degenerescência sã).
Regra definida pelo usuário 5 testes
Rule30FromUserSourceMatchesTextbook — uma regra escrita pelo usuário, compilada em runtime, bate com o livro.
Rule90FromUserSourceMatchesSierpinski — idem para Sierpinski.
UserRule90EqualsBuiltInElementary90 — a regra do usuário (neighbors[0]^neighbors[1]) produz saída idêntica ao preset embutido LocalRule_Elementary(90) ao longo de 15 passos.
BadUserCodeFailsGracefully — código inválido falha com erro claro, sem crash (graceful fail).
GameOfLifeBlinkerOscillatesViaUserRule — uma regra GoL do usuário faz o blinker oscilar com período 2.
Integração no componente 5 testes
CheckPassesAndBuildsUserDefinedRule_check aceita e compila uma regra USERDEFINED válida.
CheckRejectsUserDefinedWithoutSource — rejeita USERDEFINED sem código-fonte.
CheckRejectsUserDefinedWithBadSource — rejeita USERDEFINED com código que não compila.
CheckRejectsMissingCellularAutomataType — rejeita configuração sem tipo de AC.
PersistenceRoundTripPreservesConfiguration — salvar → carregar preserva toda a configuração (round-trip).
Vizinhanças e lattice 8 testes
Lattice — ConvertsLinearCellNumbersAndNDimPositions — conversão índice linear ↔ posição N-D fecha (round-trip).
Moore — CountsInternalNeighbors (várias dimensões/raios) · ReturnsDeterministicLexicographicOffsets · KeepsFixedBoundaryPositionsAsFixedCells.
VonNeumann — CountsInternalNeighbors · ReturnsDeterministicLexicographicOffsets · KeepsFixedBoundaryPositionsAsFixedCells.
GameOfLife — KeepsTwoDimensionalMooreBlinkerRegression — blinker 2D mantém período 2 (teste de regressão).

A verdade-base, em pixels

Estes são os diagramas exatos afirmados nos testes. O bloco da esquerda é gerado agora pelo mesmo JS das demos; o da direita é o texto literal do arquivo de evidência (a saída do C++). Eles coincidem.

Rule 30 — gerado ao vivo (largura 9, t0..t3)


      

Rule 30 — ground truth (SPEC / livro p.242)

000010000
000111000
001100100
011011110

Rule 90 — gerado ao vivo (largura 11, t0..t4)


      

Rule 90 — ground truth (Sierpinski)

00000100000
00001010000
00010001000
00101010100
01000000010
Conferência automáticaverificando…
06

Para a apresentação — pontos-chave

Clique para expandir cada pergunta. Estas são as respostas que o Pedro deve saber para apresentar e responder a banca.

Q1Por que compilação dinâmica (CppCompiler) em vez do parser?
O parser (Linha A, do Sutter) é elegante para regras simples, mas fica limitado e lento para regras arbitrárias e complexas. Compilar a regra para código nativo dá expressividade total (qualquer C++: loops, condicionais, contagens) e desempenho de código compilado — importante em ACs grandes. O próprio tema aponta a Linha B como "particularmente forte". E reusa um mecanismo já existente no GenESyS (CppForG), em vez de inventar um novo.
Q2Como funcionam dlopen e dlsym?
dlopen() carrega uma biblioteca dinâmica (.so no Linux) no processo que já está rodando, em runtime, e devolve um handle. dlsym(handle, "nextState") procura nessa lib o símbolo de nome nextState e devolve um ponteiro de função, que o simulador chama como qualquer função. Por isso a função precisa de extern "C": sem ele, o C++ "embaralha" o nome do símbolo (name mangling) e o dlsym não o acharia. No fim, dlclose libera a lib.
Q3Por que a verificação é reprodução bit-perfect, e não intervalo de confiança?
Replicação, intervalo de confiança e descarte de warm-up existem para domar a aleatoriedade de modelos estocásticos — você estima a média de algo que varia entre corridas. Um autômato celular não tem aleatoriedade: dada a configuração inicial e a regra, a evolução é totalmente determinística. Logo, "correto" é a forma mais forte de correção possível: a saída tem que ser exatamente igual à teoria, bit a bit. É isso que os 22 testes afirmam.
Q4Como a regra se integra ao componente e ao kernel?
O CellularAutomataComp é o ponto de ligação entre o kernel do GenESyS e o subsistema de AC. Ao escolher USERDEFINED, o _check() chama _buildUserDefinedRule(), que cria um CppCompiler, instancia LocalRule_UserDefined e chama build() (compila + carrega + resolve o símbolo). A regra pronta é guardada em _localRule. Quando o kernel dispara um evento (_onDispatchEvent), o componente chama _cellularAutomata->step(), que aplica a regra (e portanto a função do usuário) a todas as células e troca os estados.
Q5O que é, exatamente, o contrato da regra do usuário?
Uma função C com a assinatura extern "C" long nextState(long self, const long* neighbors, int numNeighbors). O usuário recebe o estado da própria célula e a lista de estados dos vizinhos (na ordem da vizinhança) e devolve o próximo estado. Não precisa conhecer Cell, State nem nenhuma classe do GenESyS — só aritmética de inteiros. Isso torna a regra trivial de escrever e rápida de compilar.
Q6O que é atualização síncrona em 2 fases, e por que importa?
Fase 1: aplica a regra a todas as células lendo o estado atual, gravando num "próximo estado" separado. Fase 2: troca tudo de uma vez. Sem isso (se atualizasse in-place), uma célula já alterada contaminaria o cálculo da vizinha e o resultado dependeria da ordem de varredura — quebrando a definição matemática do AC. As duas fases garantem determinismo e independência de ordem.
Q7Quais foram os cuidados de robustez (bugs/erros)?
(1) Falha graciosa: código de usuário inválido não derruba o simulador — build() retorna false com mensagem de erro, e _check() rejeita a configuração. (2) Bug de heap corrigido: foi adicionado destrutor explícito ao componente para liberar lattice, vizinhança, contorno, regra e a lib dinâmica na ordem certa (antes vazava). (3) Nome único por build: cada compilação gera uma .so com nome único, para o dlopen nunca devolver um mapeamento velho em cache.
Q8Quais as limitações e o que ficou como "arquitetura aberta"?
O foco do Pedro (Linha B) foi a regra local definida pelo usuário em ACs uniformes (mesma regra em todas as células). A arquitetura foi mantida aberta para extensões que são o foco do outro grupo (Tema 6.3): lattices verdadeiramente N-dimensionais, vizinhanças não uniformes e regras heterogêneas por região. Esses casos não foram implementados de propósito — para não invadir o escopo da outra equipe — mas o desenho (estados como long escalar, vizinhos como array ordenado) não bloqueia essa expansão.