# EME: AI-designed MMI

An AI agent designs a 1x2 multimode interference (MMI) splitter in silicon with EMode, end to end,
through the EMode MCP server. The agent reads a specification, builds the device, checks the
convergence of its simulation settings, optimizes the design with `optimize()`, verifies the
result over the band and reports. Every tool call it made was recorded, and this example replays
them, so it gives the same design every time and needs no agent to run.

The specification: a 1x2 splitter for TE light at 1550 nm, silicon 220 nm thick in silica, with an
excess loss below 0.3 dB over the 60 nm from 1520 to 1580 nm. The agent may change six quantities,
the ones a designer reaches for: the width and length of the MMI body, and the width and length of
the input taper and of the two output tapers that join the 500 nm access waveguides to the body.
The brief the agent was given is [`EME_AI_MMI_task.md`](../_downloads/b85a120b7329bb08c625928db0d915df/EME_AI_MMI_task.md).

The agent drives EMode through `emode-mcp`, the MCP server that ships with `emodeconnection`
(`pip install "emodeconnection[mcp]"`). It runs on your own machine with your own EMode license,
and works with any MCP client, such as Claude, ChatGPT, Gemini or Cursor. The tool calls of the
session are in [`EME_AI_MMI.json`](../_downloads/630dbf29f530392ecf562ac0640ab461/EME_AI_MMI.json), recorded
with [`record_session.py`](../_downloads/59f4118b6763000b8460059542f2d306/record_session.py), a proxy that
sits between any MCP client and `emode-mcp` and logs each call. Record your own sessions the same
way.

The script below sends the recorded calls to the same server in order. It prints each one,
abridged, with what it returned. It checks that the replayed S-matrix matches the recorded one, to
1e-6 in the power between the lowest modes of each port, and that the design meets the
specification over the agent’s verification sweep. A convergence run that stopped on its time
limit in the recording replays the same number of solves, so a faster machine reaches the same
settings. The notebook
[`EME_AI_MMI.ipynb`](../_downloads/ff6ae9369881ab33a0140fa1b361fdc9/EME_AI_MMI.ipynb) runs the same replay.

## The session

The agent was Claude Code running Claude Opus, given the brief and the EMode MCP server and no
other tools. It made 46 tool calls in 31 minutes against an EMode 1.0.8 development build.

- **Setup.** It built the device from five labeled cross-sections: the 500 nm input waveguide,
  the input taper’s wide end, the MMI body, the output tapers’ wide ends (two mask openings
  centered at plus and minus 750 nm) and the two 500 nm output waveguides. Five sections join
  them. The ‘TE’ boundary condition mirrors the window about its center lines, so it added a
  silica layer under the core to place the core at the window’s vertical center.
- **Convergence.** `emode_converge` on the MMI body, the most multimode cross-section, chose a
  4435 by 1644 nm window, 8 modes and a grid of about 2 nm, with the effective index of a
  higher-order mode settled to 1e-4. It stopped before one more window step that would have
  passed the 8 GB memory limit. The agent widened the window to 4800 nm so the body could grow
  during the search. A 2 nm grid of the whole device is far past the brief’s 100,000-cell limit,
  so it checked on a 9 nm grid, searched on 15 nm and swept the band on 18 nm. The 15 and 9 nm
  grids agree to 0.002 dB.
- **Search.** It ran differential evolution on the body length alone, then a one-evaluation run
  with the ports named rather than numbered, then COBYQA on all six variables, maximizing the power from the input’s fundamental mode into the output pair’s
  lowest mode. COBYQA stopped on its 50-evaluation budget, with the objective flat to 3e-4 over
  its last 15 evaluations.
- **Verification.** The excess loss at 1550 nm is 0.043 dB on the 9 nm grid. A wavelength sweep
  from 1500 to 1600 nm puts the largest excess loss in the band at 0.067 dB, at 1520 nm, and its
  1550 nm point agrees with the 9 nm result to 0.0004 dB. The agent ran the sweep first with the
  interpolated method, found its residual too large for a device this long, and reran it with
  the direct method.

Three of the calls failed during the session. Two field plots hit a bug since fixed, and the
replay reports them as working now. One `emode_evaluate` asked for a result named
`transmission`, which it doesn’t return, and the agent asked for the S-matrix instead.

The design:

| Variable                       | Value   |
|--------------------------------|---------|
| MMI body width                 | 2989 nm |
| MMI body length                | 7747 nm |
| Input taper width at the body  | 1162 nm |
| Input taper length             | 5210 nm |
| Output taper width at the body | 1131 nm |
| Output taper length            | 5028 nm |

The agent placed the output arms at plus and minus 750 nm, a quarter of the body width, and kept
them there, since their position isn’t one of the six variables. They are 1500 nm apart center to
center, so the gap is 369 nm between the taper ends and 1000 nm between the output waveguides,
above the 200 nm minimum. From the agent’s report (punctuation adjusted to these pages’ style): “The worst excess
loss across 1520 to 1580 nm is 0.067 dB, well below the 0.3 dB limit. At 1550 nm it is
0.043 dB.” It named what limits the design. Self-imaging in the body is imperfect, and about
0.7% of the power goes into higher-order and radiation modes of the arms, which sets the
smallest loss at about 0.04 dB. The body’s beat length changes with wavelength, so the loss
grows away from 1550 nm.

*This code example is licensed under the* [BSD 3-Clause License](../_static/LICENSE.html).

Python

```python
## Replays an AI agent's design of a 1x2 MMI through the EMode MCP server.
##
## The agent was given the specification in EME_AI_MMI_task.md and drove
## EMode through emode-mcp, the MCP server that ships with emodeconnection.
## record_session.py logged every tool call it made, with its arguments,
## result and time, in EME_AI_MMI.json. This script sends the same calls to
## the same server, so the design is reproduced without an agent, then checks
## the replayed S-matrix against the recorded one and the design against the
## specification. It needs: pip install "emodeconnection[mcp]"
import asyncio
import base64
import json
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np
import emodeconnection as emc

## The recorded session, next to the docs' static files unless EMODE_AGENT_SESSION names it
SESSION = Path(os.environ.get('EMODE_AGENT_SESSION') or Path(__file__).resolve().parents[2]
               / '_static' / 'agent_sessions' / 'EME_AI_MMI.json')

## The specification the agent was given
SPECIFICATION = {
    'wavelength': 1550,  # [nm] center wavelength
    'excess_loss_db': 0.3,  # [dB] largest excess loss in the band
    'band': (1520, 1580),  # [nm] the 60 nm band the excess loss holds over
}
TOLERANCE = 1e-6  # largest change in |S|^2 the replay may show


## ---------------------------------------------------------- reading results
def as_array(value):
    """An array from a tool result: a list, a {real, imag} pair or a .npy file."""
    if isinstance(value, dict) and 'file' in value:
        return np.load(value['file'], allow_pickle=False)
    if isinstance(value, dict) and 'real' in value:
        return np.asarray(value['real']) + 1j * np.asarray(value['imag'])
    return np.asarray(value)


def materialized(value):
    """`value` with every .npy file reference replaced by the array's values."""
    if isinstance(value, dict) and set(value) == {'file', 'shape', 'dtype'}:
        array = as_array(value)
        if np.iscomplexobj(array):
            return {'real': array.real.tolist(), 'imag': array.imag.tolist()}
        return array.tolist()
    if isinstance(value, dict):
        return {k: materialized(v) for k, v in value.items()}
    if isinstance(value, list):
        return [materialized(v) for v in value]
    return value


def s_matrix(result):
    """The port sizes and the data of an S-matrix in a tool result."""
    return tuple(result['port_sizes']), as_array(result['data'])


def transmission(result, modes=1):
    """Power from the input's fundamental mode into the output's lowest mode.

    The output is the pair of access waveguides, and the device is mirror
    symmetric about x = 0. Its lowest mode is the even combination of the two
    arms' fundamental TE modes, the only one the centered input excites (the
    odd one is not excited by symmetry, and boundary_condition 'TE' folds it
    out), so its power is the power in both arms.
    """
    ports, S = s_matrix(result)
    out = ports[0]
    return float(np.sum(np.abs(S[out:out + modes, 0]) ** 2))


def excess_loss_db(result):
    return -10 * np.log10(transmission(result))


def largest_difference(recorded, replayed, modes=2):
    """The largest change in |S|^2 between the lowest modes of every port."""
    ports, a = s_matrix(recorded)
    _, b = s_matrix(replayed)
    starts = np.cumsum((0,) + ports[:-1])
    index = np.concatenate([np.arange(s, s + min(modes, n)) for s, n in zip(starts, ports)])
    block = np.ix_(index, index)
    return float(np.max(np.abs(np.abs(a[block]) ** 2 - np.abs(b[block]) ** 2)))


def swept_values(arguments):
    """The values a sweep call set its key to."""
    if arguments.get('values') is not None:
        return np.asarray(arguments['values'], dtype=float)
    return np.linspace(arguments['start'], arguments['stop'], arguments['num'])


def verification(calls):
    """The wavelengths and excess losses of the last wavelength sweep of the S-matrix."""
    sweeps = [c for c in calls if c['tool'] == 'emode_sweep' and not c.get('is_error')
              and c['arguments'].get('key') == 'wavelength'
              and (c.get('result') or {}).get('status') == 'done']
    if not sweeps:
        raise ValueError('the session has no wavelength sweep of the S-matrix to check')
    last = sweeps[-1]
    wavelengths = swept_values(last['arguments'])
    losses = [excess_loss_db(S) for S in last['result']['data']['S_matrix']]
    return wavelengths, losses


def meets_specification(wavelengths, losses):
    """Whether the excess loss stays within the specification over the whole band."""
    low, high = SPECIFICATION['band']
    wavelengths = np.asarray(wavelengths, dtype=float)
    if wavelengths.min() > low or wavelengths.max() < high:
        return False
    inside = (wavelengths >= low) & (wavelengths <= high)
    return bool(np.all(np.asarray(losses)[inside] <= SPECIFICATION['excess_loss_db']))


## ------------------------------------------------------------------ replay
def load_session(path):
    session = json.loads(Path(path).read_text())
    if session.get('format') != 'emode-mcp-session' or session.get('version') != 1:
        raise ValueError(f'{path} is not an emode-mcp session log')
    return session


def replayable(tool, arguments, recorded=None):
    """The arguments to replay a call with, given its `recorded` result.

    A file path of the agent's machine is dropped. A convergence run that ran
    out of max_time stops after as many solves as it made in the recording, so a
    faster machine does not get further and apply a different window.
    """
    if tool == 'emode_open':
        return {k: v for k, v in arguments.items() if k != 'save_path'}
    data = (recorded or {}).get('data') or {}
    if tool == 'emode_converge' and data.get('status') == 'budget' and 'max_time' in arguments:
        solves = sum(stage['solves'] for stage in data['stages'])
        return {k: v for k, v in arguments.items() if k != 'max_time'} | {'max_solves': solves}
    return dict(arguments)


@dataclass
class Replay:
    calls: list = field(default_factory=list)  # each call's tool, result and seconds
    difference: float = float('nan')  # the final S-matrix's largest change in |S|^2
    final: dict | None = None  # the final emode_eme result, replayed
    figure: bytes | None = None  # a field plot of the final design


def replay(session, client_factory, output_dir, plot=None):
    """Send every recorded call to an emode-mcp server, in order.

    The last emode_close is held back until a field plot (`plot`, the
    arguments of emode_plot) has been made, when one is asked for.
    """
    from mcp.client import Client
    from emodeconnection.mcp.server import create_server

    server = create_server(client_factory=client_factory, output_dir=output_dir)
    calls = session['calls']
    recorded_final = next((c for c in reversed(calls) if c['tool'] == 'emode_eme'
                           and not c.get('is_error')), None)
    out = Replay()

    async def run():
        async with Client(server) as client:
            known = {t.name for t in (await client.list_tools()).tools}
            missing = sorted({c['tool'] for c in calls} - known)
            if missing:
                return missing
            for i, call in enumerate(calls):
                closing = call['tool'] == 'emode_close' and i == len(calls) - 1
                if closing and plot is not None:
                    image = await client.call_tool('emode_plot', plot)
                    out.figure = next((base64.b64decode(c.data) for c in image.content
                                       if c.type == 'image'), None)
                start = time.perf_counter()
                result = await client.call_tool(call['tool'],
                                                replayable(call['tool'], call['arguments'],
                                                           call.get('result')))
                seconds = time.perf_counter() - start
                # read the arrays now: the server deletes its files when it stops
                content = materialized(result.structured_content)
                if call is recorded_final and content is not None:
                    out.final = content
                    if call.get('result'):
                        out.difference = largest_difference(call['result']['data'],
                                                            content['data'])
                out.calls.append({'tool': call['tool'], 'is_error': result.is_error,
                                  'result': content, 'seconds': seconds})
        return []

    missing = asyncio.run(run())
    if missing:
        raise ValueError(f'the server has no tool {", ".join(missing)}')
    return out


def changed_outcomes(recorded, replayed):
    """The calls (numbered from 1) whose outcome changed in the replay.

    Returns the calls that worked in the recording and fail now, which stop the
    replay, and the calls that failed in the recording and work now, such as
    one that hit a bug fixed since it was recorded.
    """
    broken, fixed = [], []
    for i, (call, again) in enumerate(zip(recorded, replayed), start=1):
        before, now = bool(call.get('is_error')), bool(again['is_error'])
        if now and not before:
            broken.append(i)
        elif before and not now:
            fixed.append(i)
    return broken, fixed


def summary(call, result):
    """One line on what a call did."""
    data = (result or {}).get('data') if isinstance(result, dict) else None
    if call['tool'] == 'emode_solve' and result:
        return 'n_eff ' + ', '.join(f"{m['n_eff']:.4f}" for m in result['modes'][:3])
    if call['tool'] == 'emode_converge' and data:
        return f"{data.get('status')}, {len(data.get('rows', data.get('history', [])))} solves"
    if call['tool'] == 'emode_optimize' and data:
        return f"{data['status']} after {data['evaluations']} evaluations"
    if call['tool'] == 'emode_eme' and data:
        return f'excess loss {excess_loss_db(data):.3f} dB'
    if call['tool'] == 'emode_sweep' and data and 'S_matrix' in data:
        return f"{len(data['S_matrix'])} points"
    if result and result.get('status') == 'confirmation_required':
        return 'asked for confirmation'
    return 'done'


def abridged(arguments, width=70):
    text = json.dumps(arguments)
    return text if len(text) <= width else text[:width - 3] + '...'


## ------------------------------------------------------------------- main
if __name__ == '__main__':
    session = load_session(SESSION)
    calls = session['calls']
    agent = session.get('client') or {}
    if agent.get('name'):
        print(f"agent client: {agent['name']} {agent.get('version') or ''}".rstrip())
    print(f'recorded tool calls: {len(calls)}')
    print(f"agent session wall time: {sum(c['seconds'] for c in calls) / 60:.1f} min")

    ## A new list for each session: EMode appends to the command it is given
    factory = lambda **kwargs: emc.EMode(emode_cmd=list(sys.argv[1:]), **kwargs)
    start = time.perf_counter()
    result = replay(session, factory, Path('mcp_output').resolve(),
                    plot={'component': 'Ex', 'plane': 'z-x'})
    print(f'replay wall time: {(time.perf_counter() - start) / 60:.1f} min')

    print('\nThe tool calls, abridged:')
    for i, (call, again) in enumerate(zip(calls, result.calls)):
        print(f"{i + 1:3d} {call['tool']}({abridged(call['arguments'])}): "
              f"{summary(call, again['result'])}, {again['seconds']:.1f} s")
    broken, fixed = changed_outcomes(calls, result.calls)
    for i in fixed:
        print(f"call {i} ({calls[i - 1]['tool']}) failed in the recording and works now")
    if broken:
        first = broken[0]
        sys.exit(f"call {first} ({calls[first - 1]['tool']}) worked in the recording and fails now")

    ## The agent's design: the best point of its last optimization
    optimized = [c for c in calls if c['tool'] == 'emode_optimize'
                 and (c.get('result') or {}).get('status') == 'done']
    best = optimized[-1]['result']['data']
    print(f"\noptimizer: {best['status']} after {best['evaluations']} evaluations")
    for key, value in best['parameters'].items():
        print(f'final design, {key}: {value:.1f} nm')

    print(f'largest |S|^2 difference from the recording: {result.difference:.1e}')
    if not result.difference <= TOLERANCE:
        sys.exit('the replayed S-matrix differs from the recorded one')

    ## The verification sweep, replayed
    replayed = [c | {'result': a['result'], 'is_error': a['is_error']}
                for c, a in zip(calls, result.calls)]
    wavelengths, losses = verification(replayed)
    low, high = SPECIFICATION['band']
    inside = (wavelengths >= low) & (wavelengths <= high)
    at_center = np.interp(SPECIFICATION['wavelength'], wavelengths, losses)
    print(f'excess loss at 1550 nm: {at_center:.3f} dB')
    print(f'largest excess loss from {low} to {high} nm: {np.max(np.asarray(losses)[inside]):.3f} dB')
    met = meets_specification(wavelengths, losses)
    print(f"specification met: {'yes' if met else 'no'}")

    if result.figure is not None:
        Path('ai_mmi_field.png').write_bytes(result.figure)

    from matplotlib import pyplot as plt

    plt.figure(figsize=(6, 3.5))
    plt.plot(wavelengths, losses, marker='o', ms=3)
    plt.axhline(SPECIFICATION['excess_loss_db'], color='gray', ls='--', label='specification')
    plt.axvspan(low, high, color='gray', alpha=0.1)
    plt.xlabel('Wavelength (nm)')
    plt.ylabel('Excess loss (dB)')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.savefig('ai_mmi_spectrum.png', dpi=300, bbox_inches='tight')

    plt.figure(figsize=(6, 3.5))
    for n, run in enumerate(optimized):
        history = run['result']['data']['history']
        plt.plot([h['index'] for h in history], [h['objective'] for h in history],
                 marker='o', ms=2, ls='', label=f'optimization {n + 1}')
    plt.xlabel('Evaluation')
    plt.ylabel('Objective')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.savefig('ai_mmi_history.png', dpi=300, bbox_inches='tight')

    if not met:
        sys.exit('the design does not meet the specification')
```

Console output:

```default
recorded tool calls: 46
agent session wall time: 27.5 min
replay wall time: 52.6 min

The tool calls, abridged:
  1 emode_open({"simulation_name": "mmi1x2", "settings": {"wavelength": 1550, "win...): done, 3.4 s
  2 emode_solve({}): n_eff 2.4443, 1.3842, 1.3569, 0.4 s
  3 emode_plot({"component": "Ex"}): done, 0.9 s
  4 emode_plot({}): done, 0.8 s
  5 emode_profile({"name": "input"}): done, 0.0 s
  6 emode_shape({"shape": {"name": "core", "material": "Si", "height": 220, "etch_d...): done, 0.0 s
  7 emode_profile({"name": "in_end"}): done, 0.0 s
  8 emode_shape({"shape": {"name": "core", "material": "Si", "height": 220, "etch_d...): done, 0.0 s
  9 emode_profile({"name": "mmi"}): done, 0.0 s
 10 emode_shape({"shape": {"name": "core", "material": "Si", "height": 220, "etch_d...): done, 0.0 s
 11 emode_profile({"name": "out_end"}): done, 0.0 s
 12 emode_shape({"shape": {"name": "core", "material": "Si", "height": 220, "etch_d...): done, 0.0 s
 13 emode_profile({"name": "output"}): done, 0.0 s
 14 emode_solve({}): n_eff 2.4461, 1.5031, 1.3740, 0.4 s
 15 emode_converge({"profile": "mmi", "setting": "all", "target": "effective_index", "...): budget, 13 solves, 143.8 s
 16 emode_settings({"profile": "all", "settings": {"window_width": 4800, "window_heigh...): done, 0.0 s
 17 emode_shape({"profile": "input", "shape": {"name": "box", "material": "SiO2", "...): done, 0.0 s
 18 emode_shape({"profile": "in_end", "shape": {"name": "box", "material": "SiO2", ...): done, 0.0 s
 19 emode_shape({"profile": "mmi", "shape": {"name": "box", "material": "SiO2", "he...): done, 0.0 s
 20 emode_shape({"profile": "out_end", "shape": {"name": "box", "material": "SiO2",...): done, 0.0 s
 21 emode_shape({"profile": "output", "shape": {"name": "box", "material": "SiO2", ...): done, 0.0 s
 22 emode_shape({"shape": {"name": "box", "material": "SiO2", "height": 710}}): done, 0.0 s
 23 emode_get({}): done, 0.0 s
 24 emode_get({"key": "shapes", "profile": "mmi"}): done, 0.0 s
 25 emode_solve({}): n_eff 2.4453, 1.5003, 1.3463, 0.4 s
 26 emode_get({"key": "yc"}): done, 0.0 s
 27 emode_get({"key": "shapes"}): done, 0.0 s
 28 emode_section({"kind": "straight", "name": "in_wg", "profile": "input", "length":...): done, 0.0 s
 29 emode_section({"kind": "taper", "name": "in_taper", "profile": "input", "profile_...): done, 0.0 s
 30 emode_section({"kind": "straight", "name": "body", "profile": "mmi", "length": 8000}): done, 0.0 s
 31 emode_section({"kind": "taper", "name": "out_taper", "profile": "out_end", "profi...): done, 0.0 s
 32 emode_section({"kind": "straight", "name": "out_wg", "profile": "output", "length...): done, 0.0 s
 33 emode_sweep({"key": "section, body, length", "start": 5000, "stop": 14000, "num...): 19 points, 131.6 s
 34 emode_eme({}): excess loss 2.543 dB, 13.4 s
 35 emode_optimize({"parameters": [{"key": "section, body, length", "bounds": [5000, 1...): budget after 40 evaluations, 491.4 s
 36 emode_optimize({"parameters": [{"key": "section, body, length", "bounds": [7999, 8...): budget after 1 evaluations, 24.1 s
 37 emode_evaluate({"parameters": {"section, body, length": 7888}, "results": ["transm...): done, 12.6 s
 38 emode_evaluate({"parameters": {"section, body, length": 7888}, "results": ["S_matr...): done, 12.3 s
 39 emode_optimize({"parameters": [{"key": "shape, mmi, core, mask", "bounds": [2600, ...): time after 32 evaluations, 1023.8 s
 40 emode_settings({"profile": "all", "settings": {"x_resolution": 9, "y_resolution": 9}}): done, 0.0 s
 41 emode_evaluate({"parameters": {"shape, mmi, core, mask": 2989, "section, body, len...): done, 282.0 s
 42 emode_eme({}): excess loss 0.043 dB, 34.9 s
 43 emode_settings({"profile": "all", "settings": {"x_resolution": 18, "y_resolution":...): done, 0.0 s
 44 emode_sweep({"key": "wavelength", "values": [1500, 1520, 1535, 1550, 1565, 1580...): 7 points, 453.2 s
 45 emode_sweep({"key": "wavelength", "values": [1500, 1520, 1535, 1550, 1565, 1580...): 7 points, 518.9 s
 46 emode_close({}): done, 0.8 s
call 3 (emode_plot) failed in the recording and works now
call 4 (emode_plot) failed in the recording and works now

optimizer: budget after 50 evaluations
final design, shape, mmi, core, mask: 2989.0 nm
final design, section, body, length: 7746.6 nm
final design, shape, in_end, core, mask: 1161.7 nm
final design, section, in_taper, length: 5209.7 nm
final design, shape, out_end, core, mask: 1130.6 nm
final design, section, out_taper, length: 5027.6 nm
largest |S|^2 difference from the recording: 4.9e-11
excess loss at 1550 nm: 0.043 dB
largest excess loss from 1520 to 1580 nm: 0.067 dB
specification met: yes
```

Figures:

![image](examples/Figures/EME_AI_MMI/ai_mmi_field.png)![image](examples/Figures/EME_AI_MMI/ai_mmi_history.png)![image](examples/Figures/EME_AI_MMI/ai_mmi_spectrum.png)
