TLDR: Knill error correction is the teleportation circuit on logical qubits with the addition that the two qubits which are destructively measured in order to teleport the state are decoded to correct errors.
Let's begin with with the encoding circuit for the Steane code.
import cirq
import numpy as np
def encode(qubits: list[cirq.LineQubit]) -> cirq.Circuit:
    encoding_circuit = cirq.Circuit(
        cirq.H(qubits[0]),
        cirq.H(qubits[1]),
        cirq.H(qubits[3]),
        cirq.CNOT(qubits[0], qubits[2]),
        cirq.CNOT(qubits[3], qubits[5]),
        cirq.CNOT(qubits[1], qubits[6]),
        cirq.CNOT(qubits[0], qubits[4]),
        cirq.CNOT(qubits[3], qubits[6]),
        cirq.CNOT(qubits[1], qubits[5]),
        cirq.CNOT(qubits[0], qubits[6]),
        cirq.CNOT(qubits[1], qubits[2]),
        cirq.CNOT(qubits[3], qubits[4]),
    )
    return encoding_circuit
We can confirm that we have the correct $|0\rangle_{L}$ state
def physical_measurements_to_logical_measurements(measurements: np.ndarray) -> int:
    ket_0 = [
        [0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 1, 1, 1, 1],
        [0, 1, 1, 0, 0, 1, 1],
        [0, 1, 1, 1, 1, 0, 0],
        [1, 0, 1, 0, 1, 0, 1],
        [1, 0, 1, 1, 0, 1, 0],
        [1, 1, 0, 0, 1, 1, 0],
        [1, 1, 0, 1, 0, 0, 1],
    ]
    # |1> is the opposite of |0>
    ket_1 = [[1 - bit for bit in state] for state in ket_0]
    list_measurements = list(measurements)
    if list_measurements in ket_0:
        return 0
    elif list_measurements in ket_1:
        return 1
    else:
        raise ValueError(
            f"Your logical state is outside the Steane encoding's subspace {measurements}"
        )
def qec_simulator(circuit: cirq.Circuit, repetitions: int) -> cirq.ResultDict:
    sim = cirq.CliffordSimulator()
    results = sim.run(circuit, repetitions=repetitions)
    new_result_dict = {}
    for key in results.measurements.keys():
        results_at_key = results.measurements[key]
        results_values = []
        for result in results_at_key:
            phys = physical_measurements_to_logical_measurements(result)
            results_values.append(phys)
        new_result_dict[key] = np.array([results_values])
    results = cirq.ResultDict(
        params=cirq.ParamResolver({}), measurements=new_result_dict
    )
    return results
QUBITS = cirq.LineQubit.range(21)
print(
qec_simulator(
        encode(QUBITS[0:7]) + cirq.measure(QUBITS[0:7], key='m'), 10
    )
)
# prints
# m=0, 0, 0, 0, 0, 0, 0, 0, 0, 0
The most popular way of doing parity measurements for quantum error correction is with non-destructive measurements, where we use ancilla qubits to measure the stabilizers of the circuit. You can also perform these parity measurements destructively and detect errors from the result of the destructive measurements. We can see from the snippet below that injecting an $X$ error can be detected from the measured bitstrings.
print(
    qec_simulator(
        encode(cirq.LineQubit.range(7)) + cirq.X(QUBITS[0]) + cirq.measure(QUBITS[0:7], key='m'), 10
    )
)
# Raises ValueError
Since the stabilizers for the Steane code are $X_{3}X_{4}X_{5}X_{6}$, $X_{1}X_{3}X_{5}X_{6}$, $X_{0}X_{2}X_{4}X_{6}$, $Z_{3}Z_{4}Z_{5}Z_{6}$, $Z_{1}Z_{3}Z_{5}Z_{6}$, $Z_{0}X_{2}Z_{4}Z_{6}$, we can measure the stabilizers by performing xor operations on the appropriate indices of the bit-string. This only allows measuring Z or X stabilizers, not both. If we put a transversal CNOT between two logical qubits, one of the logical qubits will measure the $X$ stabilizer and the other will measure the $Z$ stabilizer.
def syndrome(measurements: list[int]) -> tuple[int, int, int]:
    s1 = measurements[3] ^ measurements[4] ^ measurements[5] ^ measurements[6]
    s2 = measurements[1] ^ measurements[2] ^ measurements[5] ^ measurements[6]
    s3 = measurements[0] ^ measurements[2] ^ measurements[4] ^ measurements[6]
    return s1, s2, s3
Based on the bit-string, flip the right bits to correct the errors
def correct_error(
    measurements: np.ndarray, syndrome_measurements: tuple[int, int, int]
):
    syndrome_to_index = {
        (0, 0, 1): 0,
        (0, 1, 0): 1,
        (0, 1, 1): 2,
        (1, 0, 0): 3,
        (1, 0, 1): 4,
        (1, 1, 0): 5,
        (1, 1, 1): 6,
    }
if syndrome_measurements in syndrome_to_index:
    measurements[syndrome_to_index[syndrome_measurements]] ^= 1
return measurements
Knill error correction is performing the teleportation protocol and calling correct_error while teleporting. Let's start by collecting the ingredients needed to create the logical version of the teleportation circuit.
def logical_h(q: list[cirq.LineQubit]) -> list[cirq.GateOperation]:
    return cirq.H.on_each(q)
def logical_x(q: list[cirq.LineQubit]) -> list[cirq.GateOperation]:
    return cirq.X.on_each(q)
def logical_cx(
    q: list[cirq.LineQubit], q1: list[cirq.LineQubit]
) -> list[cirq.GateOperation]:
    return [cirq.CX(i, j) for i, j in zip(q, q1)]
def common_resolve(classical_data: cirq.ClassicalDataStore, key_name: str) -> bool:
    error = list(classical_data._records[cirq.MeasurementKey(name=key_name)][0])
    s = syndrome(error)
    corrected_measurements = correct_error(error, s)
    classical_data._records[cirq.MeasurementKey(name=key_name)][0] = (
        corrected_measurements
    )
    phys = physical_measurements_to_logical_measurements(corrected_measurements)
    if phys == 1:
        return True
    elif phys == 0:
        return False
class ZCondition(cirq.KeyCondition):
    def resolve(self, classical_data: cirq.ClassicalDataStore) -> bool:
        return common_resolve(classical_data, "ancilla0")
class XCondition(cirq.KeyCondition):
    def resolve(self, classical_data: cirq.ClassicalDataStore) -> bool:
        return common_resolve(classical_data, "ancilla1")
def encode(q) -> cirq.Circuit:
    data = cirq.Circuit(
        cirq.H(q[0]),
        cirq.H(q[1]),
        cirq.H(q[3]),
        cirq.CNOT(q[0], q[2]),
        cirq.CNOT(q[3], q[5]),
        cirq.CNOT(q[1], q[6]),
        cirq.CNOT(q[0], q[4]),
        cirq.CNOT(q[3], q[6]),
        cirq.CNOT(q[1], q[5]),
        cirq.CNOT(q[0], q[6]),
        cirq.CNOT(q[1], q[2]),
        cirq.CNOT(q[3], q[4]),
    )
    return data
Finally, we can create the logical version of the teleportation circuit with an error on one of the qubits to confirm that the error correction protocol works.
def generate_logical_circuit(
    logical_gate: list[cirq.Operation], error_qubit: cirq.LineQubit
) -> cirq.Circuit:
circuit = cirq.Circuit(
    encode(QUBITS[0:7]),
    encode(QUBITS[7:14]),
    encode(QUBITS[14:21]),
    logical_gate,
    logical_h(QUBITS[7:14]),
    logical_cx(QUBITS[7:14], QUBITS[14:21]),
    logical_cx(QUBITS[0:7], QUBITS[7:14]),
    logical_h(QUBITS[0:7]),
    cirq.depolarize(p=0.5).on(error_qubit),
    cirq.measure(QUBITS[0:7], key="ancilla0"),
    cirq.measure(QUBITS[7:14], key="ancilla1"),
    *(
        cirq.Z(QUBITS[i]).with_classical_controls(
            ZCondition(key=cirq.MeasurementKey("ancilla0"))
        )
        for i in range(14, 21)
    ),
    *(
        cirq.X(QUBITS[i]).with_classical_controls(
            XCondition(key=cirq.MeasurementKey("ancilla1"))
        )
        for i in range(14, 21)
    ),
    cirq.measure(QUBITS[14:21], key="teleported_measurements"),
)
return circuit
We can now confirm that the error was corrected.
compiled_circuit = generate_logical_circuit(cirq.X.on_each(QUBITS[0:7]), QUBITS[0])
results = qec_simulator(compiled_circuit, 10)
print(results)
# ancilla0=1, 0, 0, 0, 1, 1, 1, 1, 1, 1
# ancilla1=0, 1, 1, 0, 1, 1, 0, 1, 0, 0
# teleported_measurements=1, 1, 1, 1, 1, 1, 1, 1, 1, 1