diff --git a/tatsu/grammars.py b/tatsu/grammars.py
index 8843994..aef1e77 100644
--- a/tatsu/grammars.py
+++ b/tatsu/grammars.py
@@ -296,7 +296,7 @@ class Token(Model):
         return ctx._token(self.token)
 
     def _first(self, k, f):
-        return set([(self.token,)])
+        return {(self.token,)}
 
     def _to_str(self, lean=False):
         return urepr(self.token)
@@ -333,7 +333,7 @@ class Pattern(Model):
         return ctx._pattern(self.pattern)
 
     def _first(self, k, f):
-        return set([(self.pattern,)])
+        return {(self.pattern,)}
 
     def _to_str(self, lean=False):
         parts = []
@@ -710,7 +710,7 @@ class OverrideList(NamedList):
 
 class Special(Model):
     def _first(self, k, f):
-        return set([(self.value,)])
+        return {(self.value,)}
 
     def _to_str(self, lean=False):
         return '?%s?' % self.value
@@ -780,6 +780,7 @@ class Rule(Decorator):
 
     def parse(self, ctx):
         result = self._parse_rhs(ctx, self.exp)
+        self._add_defined_attributes(result)
         if self.is_name:
             ctx._check_name()
         return result
@@ -787,14 +788,16 @@ class Rule(Decorator):
     def _parse_rhs(self, ctx, exp):
         ruleinfo = RuleInfo(self.name, exp.parse, self.is_leftrec, self.is_memoizable, self.params, self.kwparams)
         result = ctx._call(ruleinfo)
-        if isinstance(result, AST):
-            defines = compress_seq(self.defines())
-            result._define(
-                [d for d, l in defines if not l],
-                [d for d, l in defines if l]
-            )
         return result
 
+    def _add_defined_attributes(self, ast):
+        defines = compress_seq(self.defines())
+        if not isinstance(ast, (AST, Node)):
+            return
+        for d, l in defines:
+            if not hasattr(ast, d):
+                setattr(ast, d, [] if l else None)
+
     def _first(self, k, f):
         if self._first_set:
             return self._first_set

ModelBuilderSemantics doesn't include optional attributes
If I have a rule that with an optional named component, that component shows up as `None` when using the AST, but is missing from the model class when using `ModelBuilderSemantics`, see the code below:

```py
import tatsu
from tatsu.model import ModelBuilderSemantics

grammar = r"""
foo::Foo = left:identifier [ ':' right:identifier ] $ ;
identifier = /\w+/ ;
"""

grammar = tatsu.compile(grammar)

a = grammar.parse('foo : bar', semantics=ModelBuilderSemantics())
assert a.left == 'foo'
assert a.right == 'bar'

b = grammar.parse('foo', semantics=ModelBuilderSemantics())
assert b.left == 'foo'
assert b.right is None  # AttributeError: 'Foo' object has no attribute 'right'
```
diff --git a/test/grammar/semantics_test.py b/test/grammar/semantics_test.py
index 7ee837f..040336f 100644
--- a/test/grammar/semantics_test.py
+++ b/test/grammar/semantics_test.py
@@ -91,3 +91,19 @@ class SemanticsTests(unittest.TestCase):
         self.assertTrue(hasattr(ast, "b"))
 
         self.assertTrue(issubclass(D, (A, B, C)))
+
+    def test_optional_attributes(self):
+        grammar = r"""
+            foo::Foo = left:identifier [ ':' right:identifier ] $ ;
+            identifier = /\w+/ ;
+        """
+
+        grammar = compile(grammar)
+
+        a = grammar.parse('foo : bar', semantics=ModelBuilderSemantics())
+        assert a.left == 'foo'
+        assert a.right == 'bar'
+
+        b = grammar.parse('foo', semantics=ModelBuilderSemantics())
+        self.assertEquals(b.left, 'foo')
+        self.assertIsNone(b.right)

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-flake8 pytest-mypy pytest-pylint
3.9
requirements-dev.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
astroid==3.3.9
dill==0.3.9
exceptiongroup==1.2.2
filelock==3.18.0
flake8==7.2.0
iniconfig==2.1.0
isort==6.0.1
mccabe==0.7.0
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
platformdirs==4.3.7
pluggy==1.5.0
pycodestyle==2.13.0
pyflakes==3.3.1
pylint==3.3.6
pytest==8.3.5
pytest-flake8==1.3.0
pytest-mypy==1.0.0
pytest-pylint==0.21.0
-e git+https://github.com/neogeny/TatSu.git@27f5a1a90578d898a07d347d8df547bb52b912ad#egg=TatSu
tomli==2.2.1
tomlkit==0.13.2
typing_extensions==4.13.0

name: TatSu
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - astroid==3.3.9
      - dill==0.3.9
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - flake8==7.2.0
      - iniconfig==2.1.0
      - isort==6.0.1
      - mccabe==0.7.0
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pylint==3.3.6
      - pytest==8.3.5
      - pytest-flake8==1.3.0
      - pytest-mypy==1.0.0
      - pytest-pylint==0.21.0
      - tomli==2.2.1
      - tomlkit==0.13.2
      - typing-extensions==4.13.0
prefix: /opt/conda/envs/TatSu

test/grammar/semantics_test.py::SemanticsTests::test_optional_attributes
test/grammar/semantics_test.py::SemanticsTests::test_builder_basetype_codegen test/grammar/semantics_test.py::SemanticsTests::test_builder_semantics test/grammar/semantics_test.py::SemanticsTests::test_builder_subclassing
tatsu/grammars.py
tatsu/grammars.py