Repository: microsoft/qsharp
Pull request #2060 (2024-12-11 09:23:46)

Added Ising model sample
This adds Ising model sample with explanations.

msoeken: Thanks @DmitryVasilevsky , this would be the third Ising model simulation in the repository alongside https://github.com/microsoft/qsharp/blob/main/samples/estimation/estimation-ising-2D.ipynb and https://github.com/microsoft/qsharp/blob/main/samples/estimation/Dynamics.qs. Maybe we could remove Dynamics.qs in favor of this file and load this new file into estimation-ising-2D.ipynb and only do the explanation and RE in there.

Second, it would be nice, if the the simulation was wrapped into a quantum phase estimation to determine the ground state energy.

Patch:
diff --git a/.ado/publish.yml b/.ado/publish.yml
index 50d13d7d..440052ab 100644
--- a/.ado/publish.yml
+++ b/.ado/publish.yml
@@ -34,13 +34,13 @@ parameters:
   default:
   - name: linux_x86_64
     poolName: 'Azure-Pipelines-DevTools-EO'
-    imageName: 'ubuntu-20.04'
+    imageName: 'ubuntu-22.04'
     os: linux
     arch: x86_64
     additionalTargets: wasm32-unknown-unknown
   - name: linux_aarch64
     poolName: 'Azure-Pipelines-DevTools-EO'
-    imageName: 'ubuntu-20.04'
+    imageName: 'ubuntu-22.04'
     os: linux
     arch: aarch64
     additionalRustTargets: aarch64-unknown-linux-gnu wasm32-unknown-unknown
@@ -299,7 +299,7 @@ extends:
               python -m pip install auditwheel patchelf
               ls target/wheels
               ls target/wheels/*.whl | xargs auditwheel show
-              ls target/wheels/*.whl | xargs auditwheel repair --wheel-dir ./target/wheels/ --plat manylinux_2_31_x86_64
+              ls target/wheels/*.whl | xargs auditwheel repair --wheel-dir ./target/wheels/ --plat manylinux_2_35_x86_64
               rm target/wheels/*-linux_x86_64.whl
               ls target/wheels
             displayName: Run auditwheel for Linux Wheels
@@ -313,10 +313,8 @@ extends:
             condition: and(eq(variables['Agent.OS'], 'Linux'), eq(variables['arch'], 'aarch64'))
 
           - script: |
-              chmod +x ./docker/linux-aarch64/build.sh
               chmod +x ./docker/linux-aarch64/run.sh
 
-              ./docker/linux-aarch64/build.sh
               ./docker/linux-aarch64/run.sh
             displayName: Run auditwheel and python tests for Linux aarch64 Wheels
             condition: and(eq(variables['Agent.OS'], 'Linux'), eq(variables['arch'], 'aarch64'))
diff --git a/compiler/qsc/src/interpret.rs b/compiler/qsc/src/interpret.rs
index e1560bac..2c736ee7 100644
--- a/compiler/qsc/src/interpret.rs
+++ b/compiler/qsc/src/interpret.rs
@@ -11,6 +11,8 @@ mod package_tests;
 #[cfg(test)]
 mod tests;
 
+use std::rc::Rc;
+
 pub use qsc_eval::{
     debug::Frame,
     noise::PauliNoise,
@@ -21,6 +23,7 @@ pub use qsc_eval::{
     val::Value,
     StepAction, StepResult,
 };
+use qsc_hir::{global, ty};
 use qsc_linter::{HirLint, Lint, LintKind, LintLevel};
 use qsc_lowerer::{map_fir_package_to_hir, map_hir_package_to_fir};
 use qsc_partial_eval::ProgramEntry;
@@ -315,6 +318,72 @@ impl Interpreter {
         })
     }
 
+    /// Given a package ID, returns all the global items in the package.
+    /// Note this does not currently include re-exports.
+    fn package_globals(&self, package_id: PackageId) -> Vec<(Vec<Rc<str>>, Rc<str>, Value)> {
+        let mut exported_items = Vec::new();
+        let package = &self
+            .compiler
+            .package_store()
+            .get(map_fir_package_to_hir(package_id))
+            .expect("package should exist in the package store")
+            .package;
+        for global in global::iter_package(Some(map_fir_package_to_hir(package_id)), package) {
+            if let global::Kind::Term(term) = global.kind {
+                let store_item_id = fir::StoreItemId {
+                    package: package_id,
+                    item: fir::LocalItemId::from(usize::from(term.id.item)),
+                };
+                exported_items.push((
+                    global.namespace,
+                    global.name,
+                    Value::Global(store_item_id, FunctorApp::default()),
+                ));
+            }
+        }
+        exported_items
+    }
+
+    /// Get the global callables defined in the user source passed into initialization of the interpreter as `Value` instances.
+    pub fn user_globals(&self) -> Vec<(Vec<Rc<str>>, Rc<str>, Value)> {
+        self.package_globals(self.source_package)
+    }
+
+    /// Get the global callables defined in the open package being interpreted as `Value` instances, which will include any items
+    /// defined by calls to `eval_fragments` and the like.
+    pub fn source_globals(&self) -> Vec<(Vec<Rc<str>>, Rc<str>, Value)> {
+        self.package_globals(self.package)
+    }
+
+    /// Get the input and output types of a given value representing a global item.
+    /// # Panics
+    /// Panics if the item is not callable or a type that can be invoked as a callable.
+    pub fn global_tys(&self, item_id: &Value) -> Option<(ty::Ty, ty::Ty)> {
+        let Value::Global(item_id, _) = item_id else {
+            panic!("value is not a global callable");
+        };
+        let package_id = map_fir_package_to_hir(item_id.package);
+        let unit = self
+            .compiler
+            .package_store()
+            .get(package_id)
+            .expect("package should exist in the package store");
+        let item = unit
+            .package
+            .items
+            .get(qsc_hir::hir::LocalItemId::from(usize::from(item_id.item)))?;
+        match &item.kind {
+            qsc_hir::hir::ItemKind::Callable(decl) => {
+                Some((decl.input.ty.clone(), decl.output.clone()))
+            }
+            qsc_hir::hir::ItemKind::Ty(_, udt) => {
+                // We don't handle UDTs, so we return an error type that prevents later code from processing this item.
+                Some((udt.get_pure_ty(), ty::Ty::Err))
+            }
+            _ => panic!("item is not callable"),
+        }
+    }
+
     pub fn set_quantum_seed(&mut self, seed: Option<u64>) {
         self.quantum_seed = seed;
         self.sim.set_seed(seed);
@@ -467,6 +536,33 @@ impl Interpreter {
         )
     }
 
+    /// Invokes the given callable with the given arguments using the current environment, simlator, and compilation.
+    pub fn invoke(
+        &mut self,
+        receiver: &mut impl Receiver,
+        callable: Value,
+        args: Value,
+    ) -> InterpretResult {
+        qsc_eval::invoke(
+            self.package,
+            self.classical_seed,
+            &self.fir_store,
+            &mut self.env,
+            &mut self.sim,
+            receiver,
+            callable,
+            args,
+        )
+        .map_err(|(error, call_stack)| {
+            eval_error(
+                self.compiler.package_store(),
+                &self.fir_store,
+                call_stack,
+                error,
+            )
+        })
+    }
+
     /// Runs the given entry expression on a new instance of the environment and simulator,
     /// but using the current compilation.
     pub fn run(
@@ -1033,7 +1129,7 @@ impl<'a> BreakpointCollector<'a> {
             .expect("Couldn't find source file")
     }
 
-    fn add_stmt(&mut self, stmt: &qsc_fir::fir::Stmt) {
+    fn add_stmt(&mut self, stmt: &fir::Stmt) {
         let source: &Source = self.get_source(stmt.span.lo);
         if source.offset == self.offset {
             let span = stmt.span - source.offset;
diff --git a/compiler/qsc/src/lib.rs b/compiler/qsc/src/lib.rs
index dc957bfc..5847ab44 100644
--- a/compiler/qsc/src/lib.rs
+++ b/compiler/qsc/src/lib.rs
@@ -38,7 +38,8 @@ pub mod project {
 }
 
 pub use qsc_data_structures::{
-    language_features::LanguageFeatures, namespaces::*, span::Span, target::TargetCapabilityFlags,
+    functors::FunctorApp, language_features::LanguageFeatures, namespaces::*, span::Span,
+    target::TargetCapabilityFlags,
 };
 
 pub use qsc_passes::{lower_hir_to_fir, PackageType, PassContext};
diff --git a/compiler/qsc_eval/src/lib.rs b/compiler/qsc_eval/src/lib.rs
index 8655e056..e3daac3b 100644
--- a/compiler/qsc_eval/src/lib.rs
+++ b/compiler/qsc_eval/src/lib.rs
@@ -282,6 +282,51 @@ pub fn eval(
     Ok(value)
 }
 
+/// Evaluates the given callable with the given context.
+/// # Errors
+/// Returns the first error encountered during execution.
+/// # Panics
+/// On internal error where no result is returned.
+#[allow(clippy::too_many_arguments)]
+pub fn invoke(
+    package: PackageId,
+    seed: Option<u64>,
+    globals: &impl PackageStoreLookup,
+    env: &mut Env,
+    sim: &mut impl Backend<ResultType = impl Into<val::Result>>,
+    receiver: &mut impl Receiver,
+    callable: Value,
+    args: Value,
+) -> Result<Value, (Error, Vec<Frame>)> {
+    let mut state = State::new(package, Vec::new().into(), seed);
+    // Push the callable value into the state stack and then the args value so they are ready for evaluation.
+    state.set_val_register(callable);
+    state.push_val();
+    state.set_val_register(args);
+
+    // Evaluate the call, which will pop the args and callable values from the stack and then either
+    // a) prepare the call stack for the execution of the callable, or
+    // b) invoke the callable directly if it is an intrinsic.
+    state
+        .eval_call(
+            env,
+            sim,
+            globals,
+            Span::default(),
+            Span::default(),
+            receiver,
+        )
+        .map_err(|e| (e, state.get_stack_frames()))?;
+
+    // Trigger evaluation of the state until the end of the stack is reached and a return value is obtained, which will be the final
+    // result of the invocation.
+    let res = state.eval(globals, env, sim, receiver, &[], StepAction::Continue)?;
+    let StepResult::Return(value) = res else {
+        panic!("eval should always return a value");
+    };
+    Ok(value)
+}
+
 /// The type of step action to take during evaluation
 #[derive(Debug, Copy, Clone, Eq, PartialEq)]
 pub enum StepAction {
diff --git a/compiler/qsc_hir/src/global.rs b/compiler/qsc_hir/src/global.rs
index 754daf48..d4afe8fe 100644
--- a/compiler/qsc_hir/src/global.rs
+++ b/compiler/qsc_hir/src/global.rs
@@ -157,6 +157,17 @@ impl PackageIter<'_> {
                     intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic),
                 }),
             }),
+            (ItemKind::Callable(decl), None) => Some(Global {
+                namespace: Vec::new(),
+                name: alias.map_or_else(|| Rc::clone(&decl.name.name), |alias| alias.name.clone()),
+                visibility,
+                status,
+                kind: Kind::Term(Term {
+                    id,
+                    scheme: decl.scheme(),
+                    intrinsic: decl.body.body == SpecBody::Gen(SpecGen::Intrinsic),
+                }),
+            }),
             (ItemKind::Ty(name, def), Some(ItemKind::Namespace(namespace, _))) => {
                 self.next = Some(Global {
                     namespace: namespace.into(),
diff --git a/docker/linux-aarch64/Dockerfile b/docker/linux-aarch64/Dockerfile
deleted file mode 100644
index 9da3e9cf..00000000
--- a/docker/linux-aarch64/Dockerfile
+++ /dev/null
@@ -1,19 +0,0 @@
-# Copyright (c) Microsoft Corporation.
-# Licensed under the MIT License.
-
-ARG BASE_IMAGE
-FROM --platform=linux/arm64/v8 ${BASE_IMAGE}
-
-# install python and pip
-RUN apt-get update && \
-    DEBIAN_FRONTEND=noninteractive apt-get install \
-    python3-minimal python3-pip python3-venv \
-    --no-install-recommends -y && \
-    apt-get clean
-
-# We don't update pip here as we need to update it
-# inside the virtual environment. Otherwise, we get two versions
-# of pip installed, and the one outside the virtual environment
-# causes problems.
-
-ENTRYPOINT ["sh", "-c", "$*", "--"]
diff --git a/docker/linux-aarch64/build.sh b/docker/linux-aarch64/build.sh
deleted file mode 100644
index 5bc33ea2..00000000
--- a/docker/linux-aarch64/build.sh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env bash
-
-# Copyright (c) Microsoft Corporation.
-# Licensed under the MIT License.
-
-set -e
-
-SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
-echo "SCRIPT_DIR: ${SCRIPT_DIR}"
-
-BASE_IMAGE="${BASE_IMAGE:-ubuntu:20.04}"
-echo "BASE_IMAGE: ${BASE_IMAGE}"
-
-TAG="${TAG:-qsharp-linux-aarch64-runner}"
-echo "TAG: ${TAG}"
-
-docker build -t ${TAG} --build-arg BASE_IMAGE=${BASE_IMAGE} -f ${SCRIPT_DIR}/Dockerfile ${SCRIPT_DIR}
diff --git a/docker/linux-aarch64/entrypoint.sh b/docker/linux-aarch64/entrypoint.sh
index 03810366..3a555b11 100644
--- a/docker/linux-aarch64/entrypoint.sh
+++ b/docker/linux-aarch64/entrypoint.sh
@@ -11,7 +11,7 @@ echo "SCRIPT_DIR: ${SCRIPT_DIR}"
 WHEEL_ARCH="${WHEEL_ARCH:-aarch64}"
 echo "WHEEL_ARCH: ${WHEEL_ARCH}"
 
-WHEEL_PLATFORM="${WHEEL_PLATFORM:-manylinux_2_31_${WHEEL_ARCH}}"
+WHEEL_PLATFORM="${WHEEL_PLATFORM:-manylinux_2_35_${WHEEL_ARCH}}"
 echo "WHEEL_PLATFORM: ${WHEEL_PLATFORM}"
 
 PIP_DIR="${PIP_DIR:-${SCRIPT_DIR}/../../pip}"
diff --git a/docker/linux-aarch64/run.sh b/docker/linux-aarch64/run.sh
index 5387fba6..6b6302f9 100644
--- a/docker/linux-aarch64/run.sh
+++ b/docker/linux-aarch64/run.sh
@@ -8,11 +8,11 @@ set -e
 SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
 echo "SCRIPT_DIR: ${SCRIPT_DIR}"
 
-TAG="${TAG:-qsharp-linux-aarch64-runner}"
-echo "TAG: ${TAG}"
+BASE_IMAGE="${BASE_IMAGE:-mcr.microsoft.com/cbl-mariner/base/python:3.9}"
+echo "BASE_IMAGE: ${BASE_IMAGE}"
 
 VOLUME_ROOT=$(realpath ${SCRIPT_DIR}/../..)
 echo "VOLUME_ROOT: ${VOLUME_ROOT}"
 
-echo "docker run --platform linux/arm64/v8 -v ${VOLUME_ROOT}:/qsharp -e WHEEL_DIR='/qsharp/target/wheels' ${TAG} bash /qsharp/docker/linux-aarch64/entrypoint.sh"
-docker run --platform linux/arm64/v8 -v ${VOLUME_ROOT}:/qsharp -e WHEEL_DIR='/qsharp/target/wheels' ${TAG} bash /qsharp/docker/linux-aarch64/entrypoint.sh
+echo "docker run --platform linux/arm64/v8 -v ${VOLUME_ROOT}:/qsharp -e WHEEL_DIR='/qsharp/target/wheels' ${BASE_IMAGE} bash /qsharp/docker/linux-aarch64/entrypoint.sh"
+docker run --platform linux/arm64/v8 -v ${VOLUME_ROOT}:/qsharp -e WHEEL_DIR='/qsharp/target/wheels' ${BASE_IMAGE} bash /qsharp/docker/linux-aarch64/entrypoint.sh
diff --git a/fuzz/README.md b/fuzz/README.md
index c010652b..1c58cb80 100644
--- a/fuzz/README.md
+++ b/fuzz/README.md
@@ -4,7 +4,7 @@ Based on [Fuzzing with cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz.h
 
 For running locally you need the following steps.
 
-(**On Windows use [WSL](https://learn.microsoft.com/windows/wsl/).** Tested in WSL Ubuntu 20.04)
+(**On Windows use [WSL](https://learn.microsoft.com/windows/wsl/).** Tested in WSL Ubuntu 22.04)
 
 ## Prerequisites
 
@@ -329,7 +329,7 @@ See more in [LibFuzzer Corpus](https://llvm.org/docs/LibFuzzer.html#corpus).
 
 Based on [Code Coverage](https://rust-fuzz.github.io/book/cargo-fuzz/coverage.html#code-coverage).
 
-Tested in WSL Ubuntu 20.04.
+Tested in WSL Ubuntu 22.04.
 
 ### Code Coverage Prerequisites
 
diff --git a/jupyterlab/pyproject.toml b/jupyterlab/pyproject.toml
index ae838c05..8ed1e5bc 100644
--- a/jupyterlab/pyproject.toml
+++ b/jupyterlab/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 name = "qsharp-jupyterlab"
 version = "0.0.0"
 readme = "README.md"
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 classifiers = [
     "Framework :: Jupyter",
     "Framework :: Jupyter :: JupyterLab",
@@ -16,10 +16,10 @@ classifiers = [
     "License :: OSI Approved :: MIT License",
     "Programming Language :: Python",
     "Programming Language :: Python :: 3",
-    "Programming Language :: Python :: 3.8",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
 ]
 dependencies = [
 ]
diff --git a/pip/Cargo.toml b/pip/Cargo.toml
index e032dfcf..004ed6d9 100644
--- a/pip/Cargo.toml
+++ b/pip/Cargo.toml
@@ -27,12 +27,12 @@ workspace = true
 allocator = { path = "../allocator" }
 
 [target.'cfg(not(any(target_os = "windows")))'.dependencies]
-pyo3 = { workspace = true, features = ["abi3-py38", "extension-module", "num-bigint", "num-complex"] }
+pyo3 = { workspace = true, features = ["abi3-py39", "extension-module", "num-bigint", "num-complex"] }
 
 [target.'cfg(any(target_os = "windows"))'.dependencies]
 # generate-import-lib: skip requiring Python 3 distribution
 # files to be present on the (cross-)compile host system.
-pyo3 = { workspace = true, features = ["abi3-py38", "extension-module", "generate-import-lib", "num-bigint", "num-complex"] }
+pyo3 = { workspace = true, features = ["abi3-py39", "extension-module", "generate-import-lib", "num-bigint", "num-complex"] }
 
 [lib]
 crate-type = ["cdylib"]
diff --git a/pip/pyproject.toml b/pip/pyproject.toml
index 62b0393c..0ca5d6c4 100644
--- a/pip/pyproject.toml
+++ b/pip/pyproject.toml
@@ -1,15 +1,16 @@
 [project]
 name = "qsharp"
 version = "0.0.0"
-requires-python = ">= 3.8"
+requires-python = ">= 3.9"
 classifiers = [
     "License :: OSI Approved :: MIT License",
     "Development Status :: 5 - Production/Stable",
     "Intended Audience :: Developers",
-    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
     "Programming Language :: Python",
     "Programming Language :: Rust",
     "Operating System :: MacOS",
diff --git a/pip/qsharp/__init__.py b/pip/qsharp/__init__.py
index 3b9984cf..d588a68e 100644
--- a/pip/qsharp/__init__.py
+++ b/pip/qsharp/__init__.py
@@ -56,5 +56,5 @@ __all__ = [
     "PauliNoise",
     "DepolarizingNoise",
     "BitFlipNoise",
-    "PhaseFlipNoise"
+    "PhaseFlipNoise",
 ]
diff --git a/pip/qsharp/_native.pyi b/pip/qsharp/_native.pyi
index 0bf47a2d..328ccb0f 100644
--- a/pip/qsharp/_native.pyi
+++ b/pip/qsharp/_native.pyi
@@ -103,6 +103,13 @@ class TargetProfile(Enum):
     Describes the unrestricted set of capabilities required to run any Q# program.
     """
 
+class GlobalCallable:
+    """
+    A callable reference that can be invoked with arguments.
+    """
+
+    ...
+
 class Interpreter:
     """A Q# interpreter."""
 
@@ -114,6 +121,7 @@ class Interpreter:
         read_file: Callable[[str], Tuple[str, str]],
         list_directory: Callable[[str], List[Dict[str, str]]],
         resolve_path: Callable[[str, str], str],
+        make_callable: Optional[Callable[[GlobalCallable], None]],
     ) -> None:
         """
         Initializes the Q# interpreter.
@@ -123,6 +131,7 @@ class Interpreter:
         :param read_file: A function that reads a file from the file system.
         :param list_directory: A function that lists the contents of a directory.
         :param resolve_path: A function that joins path segments and normalizes the resulting path.
+        :param make_callable: A function that registers a Q# callable in the in the environment module.
         """
         ...
 
@@ -159,6 +168,22 @@ class Interpreter:
         """
         ...
 
+    def invoke(
+        self,
+        callable: GlobalCallable,
+        args: Any,
+        output_fn: Callable[[Output], None],
+    ) -> Any:
+        """
+        Invokes the callable with the given arguments, converted into the appropriate Q# values.
+        :param callable: The callable to invoke.
+        :param args: The arguments to pass to the callable.
+        :param output_fn: A callback function that will be called with each output.
+        :returns values: A result or runtime errors.
+        :raises QSharpError: If there is an error interpreting the input.
+        """
+        ...
+
     def qir(self, entry_expr: str) -> str:
         """
         Generates QIR from Q# source code.
@@ -262,6 +287,9 @@ class Output:
     def __str__(self) -> str: ...
     def _repr_markdown_(self) -> Optional[str]: ...
     def state_dump(self) -> Optional[StateDumpData]: ...
+    def is_state_dump(self) -> bool: ...
+    def is_matrix(self) -> bool: ...
+    def is_message(self) -> bool: ...
 
 class StateDumpData:
     """
diff --git a/pip/qsharp/_qsharp.py b/pip/qsharp/_qsharp.py
index 84a083c7..198b52db 100644
--- a/pip/qsharp/_qsharp.py
+++ b/pip/qsharp/_qsharp.py
@@ -1,7 +1,7 @@
 # Copyright (c) Microsoft Corporation.
 # Licensed under the MIT License.
 
-from . import telemetry_events
+from . import telemetry_events, code
 from ._native import (
     Interpreter,
     TargetProfile,
@@ -9,6 +9,7 @@ from ._native import (
     QSharpError,
     Output,
     Circuit,
+    GlobalCallable,
 )
 from typing import (
     Any,
@@ -24,6 +25,8 @@ from typing import (
 from .estimator._estimator import EstimatorResult, EstimatorParams
 import json
 import os
+import sys
+import types
 from time import monotonic
 
 _interpreter = None
@@ -190,6 +193,25 @@ def init(
                 f"Error reading {qsharp_json}. qsharp.json should exist at the project root and be a valid JSON file."
             ) from e
 
+    # Loop through the environment module and remove any dynamically added attributes that represent
+    # Q# callables. This is necessary to avoid conflicts with the new interpreter instance.
+    keys_to_remove = []
+    for key in code.__dict__:
+        if hasattr(code.__dict__[key], "__qs_gen") or isinstance(
+            code.__dict__[key], types.ModuleType
+        ):
+            keys_to_remove.append(key)
+    for key in keys_to_remove:
+        code.__delattr__(key)
+
+    # Also remove any namespace modules dynamically added to the system.
+    keys_to_remove = []
+    for key in sys.modules:
+        if key.startswith("qsharp.code."):
+            keys_to_remove.append(key)
+    for key in keys_to_remove:
+        sys.modules.__delitem__(key)
+
     _interpreter = Interpreter(
         target_profile,
         language_features,
@@ -198,6 +220,7 @@ def init(
         list_directory,
         resolve,
         fetch_github,
+        _make_callable,
     )
 
     _config = Config(target_profile, language_features, manifest_contents, project_root)
@@ -219,18 +242,133 @@ def get_interpreter() -> Interpreter:
     return _interpreter
 
 
-def eval(source: str) -> Any:
+class StateDump:
+    """
+    A state dump returned from the Q# interpreter.
+    """
+
+    """
+    The number of allocated qubits at the time of the dump.
+    """
+    qubit_count: int
+
+    __inner: dict
+    __data: StateDumpData
+
+    def __init__(self, data: StateDumpData):
+        self.__data = data
+        self.__inner = data.get_dict()
+        self.qubit_count = data.qubit_count
+
+    def __getitem__(self, index: int) -> complex:
+        return self.__inner.__getitem__(index)
+
+    def __iter__(self):
+        return self.__inner.__iter__()
+
+    def __len__(self) -> int:
+        return len(self.__inner)
+
+    def __repr__(self) -> str:
+        return self.__data.__repr__()
+
+    def __str__(self) -> str:
+        return self.__data.__str__()
+
+    def _repr_markdown_(self) -> str:
+        return self.__data._repr_markdown_()
+
+    def check_eq(
+        self, state: Union[Dict[int, complex], List[complex]], tolerance: float = 1e-10
+    ) -> bool:
+        """
+        Checks if the state dump is equal to the given state. This is not mathematical equality,
+        as the check ignores global phase.
+
+        :param state: The state to check against, provided either as a dictionary of state indices to complex amplitudes,
+            or as a list of real amplitudes.
+        :param tolerance: The tolerance for the check. Defaults to 1e-10.
+        """
+        phase = None
+        # Convert a dense list of real amplitudes to a dictionary of state indices to complex amplitudes
+        if isinstance(state, list):
+            state = {i: val for i, val in enumerate(state)}
+        # Filter out zero states from the state dump and the given state based on tolerance
+        state = {k: v for k, v in state.items() if abs(v) > tolerance}
+        inner_state = {k: v for k, v in self.__inner.items() if abs(v) > tolerance}
+        if len(state) != len(inner_state):
+            return False
+        for key in state:
+            if key not in inner_state:
+                return False
+            if phase is None:
+                # Calculate the phase based on the first state pair encountered.
+                # Every pair of states after this must have the same phase for the states to be equivalent.
+                phase = inner_state[key] / state[key]
+            elif abs(phase - inner_state[key] / state[key]) > tolerance:
+                # This pair of states does not have the same phase,
+                # within tolerance, so the equivalence check fails.
+                return False
+        return True
+
+    def as_dense_state(self) -> List[complex]:
+        """
+        Returns the state dump as a dense list of complex amplitudes. This will include zero amplitudes.
+        """
+        return [self.__inner.get(i, complex(0)) for i in range(2**self.qubit_count)]
+
+
+class ShotResult(TypedDict):
+    """
+    A single result of a shot.
+    """
+
+    events: List[Output]
+    result: Any
+    messages: List[str]
+    matrices: List[Output]
+    dumps: List[StateDump]
+
+
+def eval(
+    source: str,
+    *,
+    save_events: bool = False,
+) -> Any:
     """
     Evaluates Q# source code.
 
     Output is printed to console.
 
     :param source: The Q# source code to evaluate.
-    :returns value: The value returned by the last statement in the source code.
+    :param save_events: If true, all output will be saved and returned. If false, they will be printed.
+    :returns value: The value returned by the last statement in the source code or the saved output if `save_events` is true.
     :raises QSharpError: If there is an error evaluating the source code.
     """
     ipython_helper()
 
+    results: ShotResult = {
+        "events": [],
+        "result": None,
+        "messages": [],
+        "matrices": [],
+        "dumps": [],
+    }
+
+    def on_save_events(output: Output) -> None:
+        # Append the output to the last shot's output list
+        if output.is_matrix():
+            results["events"].append(output)
+            results["matrices"].append(output)
+        elif output.is_state_dump():
+            state_dump = StateDump(output.state_dump())
+            results["events"].append(state_dump)
+            results["dumps"].append(state_dump)
+        elif output.is_message():
+            stringified = str(output)
+            results["events"].append(stringified)
+            results["messages"].append(stringified)
+
     def callback(output: Output) -> None:
         if _in_jupyter:
             try:
@@ -244,21 +382,67 @@ def eval(source: str) -> Any:
     telemetry_events.on_eval()
     start_time = monotonic()
 
-    results = get_interpreter().interpret(source, callback)
+    results["result"] = get_interpreter().interpret(
+        source, on_save_events if save_events else callback
+    )
 
     durationMs = (monotonic() - start_time) * 1000
     telemetry_events.on_eval_end(durationMs)
 
-    return results
+    if save_events:
+        return results
+    else:
+        return results["result"]
+
+
+# Helper function that knows how to create a function that invokes a callable. This will be
+# used by the underlying native code to create functions for callables on the fly that know
+# how to get the currently intitialized global interpreter instance.
+def _make_callable(callable: GlobalCallable, namespace: List[str], callable_name: str):
+    module = code
+    # Create a name that will be used to collect the hierachy of namespace identifiers if they exist and use that
+    # to register created modules with the system.
+    accumulated_namespace = "qsharp.code"
+    accumulated_namespace += "."
+    for name in namespace:
+        accumulated_namespace += name
+        # Use the existing entry, which should already be a module.
+        if hasattr(module, name):
+            module = module.__getattribute__(name)
+        else:
+            # This namespace entry doesn't exist as a module yet, so create it, add it to the environment, and
+            # add it to sys.modules so it supports import properly.
+            new_module = types.ModuleType(accumulated_namespace)
+            module.__setattr__(name, new_module)
+            sys.modules[accumulated_namespace] = new_module
+            module = new_module
+        accumulated_namespace += "."
 
+    def _callable(*args):
+        ipython_helper()
 
-class ShotResult(TypedDict):
-    """
-    A single result of a shot.
-    """
+        def callback(output: Output) -> None:
+            if _in_jupyter:
+                try:
+                    display(output)
+                    return
+                except:
+                    # If IPython is not available, fall back to printing the output
+                    pass
+            print(output, flush=True)
 
-    events: List[Output]
-    result: Any
+        if len(args) == 1:
+            args = args[0]
+        elif len(args) == 0:
+            args = None
+
+        return get_interpreter().invoke(callable, args, callback)
+
+    # Each callable is annotated so that we know it is auto-generated and can be removed on a re-init of the interpreter.
+    _callable.__qs_gen = True
+
+    # Add the callable to the module.
+    module.__setattr__(callable_name, _callable)
 
 
 def run(
@@ -315,9 +499,17 @@ def run(
     def on_save_events(output: Output) -> None:
         # Append the output to the last shot's output list
         results[-1]["events"].append(output)
+        if output.is_matrix():
+            results[-1]["matrices"].append(output)
+        elif output.is_state_dump():
+            results[-1]["dumps"].append(StateDump(output.state_dump()))
+        elif output.is_message():
+            results[-1]["messages"].append(str(output))
 
     for shot in range(shots):
-        results.append({"result": None, "events": []})
+        results.append(
+            {"result": None, "events": [], "messages": [], "matrices": [], "dumps": []}
+        )
         run_results = get_interpreter().run(
             entry_expr,
             on_save_events if save_events else print_output,
@@ -482,82 +674,6 @@ def set_classical_seed(seed: Optional[int]) -> None:
     get_interpreter().set_classical_seed(seed)
 
 
-class StateDump:
-    """
-    A state dump returned from the Q# interpreter.
-    """
-
-    """
-    The number of allocated qubits at the time of the dump.
-    """
-    qubit_count: int
-
-    __inner: dict
-    __data: StateDumpData
-
-    def __init__(self, data: StateDumpData):
-        self.__data = data
-        self.__inner = data.get_dict()
-        self.qubit_count = data.qubit_count
-
-    def __getitem__(self, index: int) -> complex:
-        return self.__inner.__getitem__(index)
-
-    def __iter__(self):
-        return self.__inner.__iter__()
-
-    def __len__(self) -> int:
-        return len(self.__inner)
-
-    def __repr__(self) -> str:
-        return self.__data.__repr__()
-
-    def __str__(self) -> str:
-        return self.__data.__str__()
-
-    def _repr_markdown_(self) -> str:
-        return self.__data._repr_markdown_()
-
-    def check_eq(
-        self, state: Union[Dict[int, complex], List[complex]], tolerance: float = 1e-10
-    ) -> bool:
-        """
-        Checks if the state dump is equal to the given state. This is not mathematical equality,
-        as the check ignores global phase.
-
-        :param state: The state to check against, provided either as a dictionary of state indices to complex amplitudes,
-            or as a list of real amplitudes.
-        :param tolerance: The tolerance for the check. Defaults to 1e-10.
-        """
-        phase = None
-        # Convert a dense list of real amplitudes to a dictionary of state indices to complex amplitudes
-        if isinstance(state, list):
-            state = {i: state[i] for i in range(len(state))}
-        # Filter out zero states from the state dump and the given state based on tolerance
-        state = {k: v for k, v in state.items() if abs(v) > tolerance}
-        inner_state = {k: v for k, v in self.__inner.items() if abs(v) > tolerance}
-        if len(state) != len(inner_state):
-            return False
-        for key in state:
-            if key not in inner_state:
-                return False
-            if phase is None:
-                # Calculate the phase based on the first state pair encountered.
-                # Every pair of states after this must have the same phase for the states to be equivalent.
-                phase = inner_state[key] / state[key]
-            elif abs(phase - inner_state[key] / state[key]) > tolerance:
-                # This pair of states does not have the same phase,
-                # within tolerance, so the equivalence check fails.
-                return False
-        return True
-
-    def as_dense_state(self) -> List[complex]:
-        """
-        Returns the state dump as a dense list of complex amplitudes. This will include zero amplitudes.
-        """
-        return [self.__inner.get(i, complex(0)) for i in range(2**self.qubit_count)]
-
-
 def dump_machine() -> StateDump:
     """
     Returns the sparse state vector of the simulator as a StateDump object.
diff --git a/pip/qsharp/code/__init__.py b/pip/qsharp/code/__init__.py
new file mode 100644
index 00000000..695b54fb
--- /dev/null
+++ b/pip/qsharp/code/__init__.py
@@ -0,0 +1,6 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+"""
+Code module that receives any user-defined Q# callables as Python functions.
+"""
diff --git a/pip/src/interpreter.rs b/pip/src/interpreter.rs
index 78b4b00a..6f1c41a2 100644
--- a/pip/src/interpreter.rs
+++ b/pip/src/interpreter.rs
@@ -11,16 +11,17 @@ use crate::{
     noisy_simulator::register_noisy_simulator_submodule,
 };
 use miette::{Diagnostic, Report};
-use num_bigint::BigUint;
+use num_bigint::{BigInt, BigUint};
 use num_complex::Complex64;
 use pyo3::{
     create_exception,
     exceptions::{PyException, PyValueError},
     prelude::*,
-    types::{PyComplex, PyDict, PyList, PyTuple, PyType},
+    types::{PyComplex, PyDict, PyList, PyString, PyTuple, PyType},
 };
 use qsc::{
-    fir,
+    fir::{self},
+    hir::ty::{Prim, Ty},
     interpret::{
         self,
         output::{Error, Receiver},
@@ -73,6 +74,7 @@ fn _native<'a>(py: Python<'a>, m: &Bound<'a, PyModule>) -> PyResult<()> {
     m.add_class::<Output>()?;
     m.add_class::<StateDumpData>()?;
     m.add_class::<Circuit>()?;
+    m.add_class::<GlobalCallable>()?;
     m.add_function(wrap_pyfunction!(physical_estimates, m)?)?;
     m.add("QSharpError", py.get_type_bound::<QSharpError>())?;
     register_noisy_simulator_submodule(py, m)?;
@@ -213,9 +215,12 @@ impl From<ProgramType> for qsc_qasm3::ProgramType {
     }
 }
 
+#[allow(clippy::struct_field_names)]
 #[pyclass(unsendable)]
 pub(crate) struct Interpreter {
     pub(crate) interpreter: interpret::Interpreter,
+    /// The Python function to call to create a new function wrapping a callable invocation.
+    pub(crate) make_callable: Option<PyObject>,
 }
 
 thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = Rc::default(); }
@@ -225,7 +230,7 @@ thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = Rc::default();
 impl Interpreter {
     #[allow(clippy::too_many_arguments)]
     #[allow(clippy::needless_pass_by_value)]
-    #[pyo3(signature = (target_profile, language_features=None, project_root=None, read_file=None, list_directory=None, resolve_path=None, fetch_github=None))]
+    #[pyo3(signature = (target_profile, language_features=None, project_root=None, read_file=None, list_directory=None, resolve_path=None, fetch_github=None, make_callable=None))]
     #[new]
     /// Initializes a new Q# interpreter.
     pub(crate) fn new(
@@ -237,6 +242,7 @@ impl Interpreter {
         list_directory: Option<PyObject>,
         resolve_path: Option<PyObject>,
         fetch_github: Option<PyObject>,
+        make_callable: Option<PyObject>,
     ) -> PyResult<Self> {
         let target = Into::<Profile>::into(target_profile).into();
 
@@ -278,7 +284,19 @@ impl Interpreter {
             buildable_program.store,
             &buildable_program.user_code_dependencies,
         ) {
-            Ok(interpreter) => Ok(Self { interpreter }),
+            Ok(interpreter) => {
+                if let Some(make_callable) = &make_callable {
+                    // Add any global callables from the user source as Python functions to the environment.
+                    let exported_items = interpreter.user_globals();
+                    for (namespace, name, val) in exported_items {
+                        create_py_callable(py, make_callable, &namespace, &name, val)?;
+                    }
+                }
+                Ok(Self {
+                    interpreter,
+                    make_callable,
+                })
+            }
             Err(errors) => Err(QSharpError::new_err(format_errors(errors))),
         }
     }
@@ -300,7 +318,19 @@ impl Interpreter {
     ) -> PyResult<PyObject> {
         let mut receiver = OptionalCallbackReceiver { callback, py };
         match self.interpreter.eval_fragments(&mut receiver, input) {
-            Ok(value) => Ok(ValueWrapper(value).into_py(py)),
+            Ok(value) => {
+                if let Some(make_callable) = &self.make_callable {
+                    // Get any global callables from the evaluated input and add them to the environment. This will grab
+                    // every callable that was defined in the input and by previous calls that added to the open package.
+                    // This is safe because either the callable will be replaced with itself or a new callable with the
+                    // same name will shadow the previous one, which is the expected behavior.
+                    let new_items = self.interpreter.source_globals();
+                    for (namespace, name, val) in new_items {
+                        create_py_callable(py, make_callable, &namespace, &name, val)?;
+                    }
+                }
+                Ok(ValueWrapper(value).into_py(py))
+            }
             Err(errors) => Err(QSharpError::new_err(format_errors(errors))),
         }
     }
@@ -357,6 +387,56 @@ impl Interpreter {
         }
     }
 
+    #[pyo3(signature=(callable, args=None, callback=None))]
+    fn invoke(
+        &mut self,
+        py: Python,
+        callable: GlobalCallable,
+        args: Option<PyObject>,
+        callback: Option<PyObject>,
+    ) -> PyResult<PyObject> {
+        let mut receiver = OptionalCallbackReceiver { callback, py };
+        let (input_ty, output_ty) = self
+            .interpreter
+            .global_tys(&callable.0)
+            .ok_or(QSharpError::new_err("callable not found"))?;
+
+        // If the types are not supported, we can't convert the arguments or return value.
+        // Check this before trying to convert the arguments, and return an error if the types are not supported.
+        if let Some(ty) = first_unsupported_interop_ty(&input_ty) {
+            return Err(QSharpError::new_err(format!(
+                "unsupported input type: `{ty}`"
+            )));
+        }
+        if let Some(ty) = first_unsupported_interop_ty(&output_ty) {
+            return Err(QSharpError::new_err(format!(
+                "unsupported output type: `{ty}`"
+            )));
+        }
+
+        // Conver the Python arguments to Q# values, treating None as an empty tuple aka `Unit`.
+        let args = if matches!(&input_ty, Ty::Tuple(tup) if tup.is_empty()) {
+            // Special case for unit, where args should be None
+            if args.is_some() {
+                return Err(QSharpError::new_err("expected no arguments"));
+            }
+            Value::unit()
+        } else {
+            let Some(args) = args else {
+                return Err(QSharpError::new_err(format!(
+                    "expected arguments of type `{input_ty}`"
+                )));
+            };
+            // This conversion will produce errors if the types don't match or can't be converted.
+            convert_obj_with_ty(py, &args, &input_ty)?
+        };
+
+        match self.interpreter.invoke(&mut receiver, callable.0, args) {
+            Ok(value) => Ok(ValueWrapper(value).into_py(py)),
+            Err(errors) => Err(QSharpError::new_err(format_errors(errors))),
+        }
+    }
+
     fn qir(&mut self, _py: Python, entry_expr: &str) -> PyResult<String> {
         match self.interpreter.qirgen(entry_expr) {
             Ok(qir) => Ok(qir),
@@ -494,6 +574,85 @@ impl Interpreter {
     }
 }
 
+/// Finds any Q# type recursively that does not support interop with Python, meaning our code cannot convert it back and forth
+/// across the interop boundary.
+fn first_unsupported_interop_ty(ty: &Ty) -> Option<&Ty> {
+    match ty {
+        Ty::Prim(prim_ty) => match prim_ty {
+            Prim::Pauli
+            | Prim::BigInt
+            | Prim::Bool
+            | Prim::Double
+            | Prim::Int
+            | Prim::String
+            | Prim::Result => None,
+            Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => {
+                Some(ty)
+            }
+        },
+        Ty::Tuple(tup) => tup
+            .iter()
+            .find(|t| first_unsupported_interop_ty(t).is_some()),
+        Ty::Array(ty) => first_unsupported_interop_ty(ty),
+        _ => Some(ty),
+    }
+}
+
+/// Given a type, convert a Python object into a Q# value of that type. This will recur through tuples and arrays,
+/// and will return an error if the type is not supported or the object cannot be converted.
+fn convert_obj_with_ty(py: Python, obj: &PyObject, ty: &Ty) -> PyResult<Value> {
+    match ty {
+        Ty::Prim(prim_ty) => match prim_ty {
+            Prim::BigInt => Ok(Value::BigInt(obj.extract::<BigInt>(py)?)),
+            Prim::Bool => Ok(Value::Bool(obj.extract::<bool>(py)?)),
+            Prim::Double => Ok(Value::Double(obj.extract::<f64>(py)?)),
+            Prim::Int => Ok(Value::Int(obj.extract::<i64>(py)?)),
+            Prim::String => Ok(Value::String(obj.extract::<String>(py)?.into())),
+            Prim::Result => Ok(Value::Result(qsc::interpret::Result::Val(
+                obj.extract::<Result>(py)? == Result::One,
+            ))),
+            Prim::Pauli => Ok(Value::Pauli(match obj.extract::<Pauli>(py)? {
+                Pauli::I => fir::Pauli::I,
+                Pauli::X => fir::Pauli::X,
+                Pauli::Y => fir::Pauli::Y,
+                Pauli::Z => fir::Pauli::Z,
+            })),
+            Prim::Qubit | Prim::Range | Prim::RangeTo | Prim::RangeFrom | Prim::RangeFull => {
+                unimplemented!("primitive input type: {prim_ty:?}")
+            }
+        },
+        Ty::Tuple(tup) => {
+            if tup.len() == 1 {
+                let value = convert_obj_with_ty(py, obj, &tup[0]);
+                Ok(Value::Tuple(vec![value?].into()))
+            } else {
+                let obj = obj.extract::<Vec<PyObject>>(py)?;
+                if obj.len() != tup.len() {
+                    return Err(QSharpError::new_err(format!(
+                        "mismatched tuple arity: expected {}, got {}",
+                        tup.len(),
+                        obj.len()
+                    )));
+                }
+                let mut values = Vec::with_capacity(obj.len());
+                for (i, ty) in tup.iter().enumerate() {
+                    values.push(convert_obj_with_ty(py, &obj[i], ty)?);
+                }
+                Ok(Value::Tuple(values.into()))
+            }
+        }
+        Ty::Array(ty) => {
+            let obj = obj.extract::<Vec<PyObject>>(py)?;
+            let mut values = Vec::with_capacity(obj.len());
+            for item in &obj {
+                values.push(convert_obj_with_ty(py, item, ty)?);
+            }
+            Ok(Value::Array(values.into()))
+        }
+        _ => unimplemented!("input type: {ty}"),
+    }
+}
+
 #[pyfunction]
 pub fn physical_estimates(logical_resources: &str, job_params: &str) -> PyResult<String> {
     match re::estimate_physical_resources_from_json(logical_resources, job_params) {
@@ -587,6 +746,18 @@ impl Output {
             DisplayableOutput::Matrix(_) | DisplayableOutput::Message(_) => None,
         }
     }
+
+    fn is_state_dump(&self) -> bool {
+        matches!(&self.0, DisplayableOutput::State(_))
+    }
+
+    fn is_matrix(&self) -> bool {
+        matches!(&self.0, DisplayableOutput::Matrix(_))
+    }
+
+    fn is_message(&self) -> bool {
+        matches!(&self.0, DisplayableOutput::Message(_))
+    }
 }
 
 #[pyclass]
@@ -828,3 +999,47 @@ where
         PyException::new_err(message)
     }
 }
+
+#[pyclass(unsendable)]
+#[derive(Clone)]
+struct GlobalCallable(Value);
+
+impl From<Value> for GlobalCallable {
+    fn from(val: Value) -> Self {
+        match val {
+            val @ Value::Global(..) => GlobalCallable(val),
+            _ => panic!("expected global callable"),
+        }
+    }
+}
+
+impl From<GlobalCallable> for Value {
+    fn from(val: GlobalCallable) -> Self {
+        val.0
+    }
+}
+
+/// Create a Python callable from a Q# callable and adds it to the given environment.
+fn create_py_callable(
+    py: Python,
+    make_callable: &PyObject,
+    namespace: &[Rc<str>],
+    name: &str,
+    val: Value,
+) -> PyResult<()> {
+    if namespace.is_empty() && name == "<lambda>" {
+        // We don't want to bind auto-generated lambda callables.
+        return Ok(());
+    }
+
+    let args = (
+        Py::new(py, GlobalCallable::from(val)).expect("should be able to create callable"), // callable id
+        PyList::new_bound(py, namespace.iter().map(ToString::to_string)), // namespace as string array
+        PyString::new_bound(py, name),                                    // name of callable
+    );
+
+    // Call into the Python layer to create the function wrapping the callable invocation.
+    make_callable.call1(py, args)?;
+
+    Ok(())
+}
diff --git a/samples/algorithms/SimpleIsing.qs b/samples/algorithms/SimpleIsing.qs
new file mode 100644
index 00000000..61945ca0
--- /dev/null
+++ b/samples/algorithms/SimpleIsing.qs
@@ -0,0 +1,113 @@
+/// # Sample
+/// Simulation of a simple Ising model evolution on a 2D grid
+///
+/// # Description
+/// This example demonstrates simulation of an Ising model Hamiltonian
+/// on an N1xN2 2D grid. This example can be easily simulated classically
+/// with 3x3 grid and about 500 shots.
+/// This sample is suitable for Base Profile.
+/// For the purpose of simplicity this sample intentionally doesn't
+/// post-process results or perform eigenvalue estimation.
+operation Main() : Result[] {
+    // Dimensions of a 2D grid is N1 x N2
+    let N1 : Int = 3;
+    let N2 : Int = 3;
+
+    // Total evolution time
+    let evolutionTime : Double = 4.0;
+    // Number of steps
+    let numberOfSteps : Int = 5;
+
+    // Coefficient for 2-qubit interactions between neighboring qubits
+    let J : Double = 1.0;
+    // Coefficient for external field interaction for individual qubits
+    let g : Double = 1.4;
+
+    // Also try simulating with different strength of external field:
+    // let g = 0.2;
+    // let g = 1.0;
+    // let g = 1.4;
+    // let g = 2.0;
+
+    // Model evolution
+    IsingModel2DEvolution(N1, N2, J, g, evolutionTime, numberOfSteps)
+}
+
+/// # Summary
+/// Simulate simple Ising model evolution
+///
+/// # Description
+/// Simulates state |𝜓⟩ evolution to find |𝜓(t)⟩=U(t)|𝜓(0)⟩.
+/// |𝜓(0)⟩ is taken to be |0...0⟩.
+/// U(t)=e⁻ⁱᴴᵗ, where H is an Ising model Hamiltonian H = -J·Σ'ᵢⱼZᵢZⱼ + g·ΣᵢXᵢ
+/// Here Σ' is taken over all pairs of neighboring qubits <i,j>.
+/// Simulation is done by performing K steps assuming U(t)≈U(t/K)ᴷ.
+operation IsingModel2DEvolution(
+    N1 : Int,
+    N2 : Int,
+    J : Double,
+    g : Double,
+    evolutionTime : Double,
+    numberOfSteps : Int
+) : Result[] {
+
+    // Allocate qubit grid and structure it as a 2D array.
+    use qubits = Qubit[N1 * N2];
+    let qubitsAs2D = Std.Arrays.Chunks(N2, qubits);
+
+    // Compute the step time
+    import Std.Convert.IntAsDouble;
+    let stepTime : Double = evolutionTime / IntAsDouble(numberOfSteps);
+
+    let theta_x = - g * stepTime;
+    let theta_zz = J * stepTime;
+
+    // Perform K steps
+    for i in 1..numberOfSteps {
+
+        // Single-qubit interaction with external field
+        for q in qubits {
+            Rx(2.0 * theta_x, q);
+        }
+
+        // All Rzz gates applied in the following two loops commute so they can be
+        // applied in any order. To reduce the depth of the algorithm, Rzz gates
+        // between horizontal "even" pairs of qubits are applied first - pairs
+        // that start at even indices. Then Rzz gates between "odd" pairs are
+        // applied. That way all Rzz between horizontal "even" pairs can potentially
+        // be done in parallel. Same is true about horizontal "odd"  pairs,
+        // vertical "even" pairs and vertical "odd" pairs.
+
+        // Horizontal two-qubit interactions
+        for row in 0..N1-1 {
+            // Horizontal interactions between "even" pairs
+            for col in 0..2..N2-2 {
+                Rzz(2.0 * theta_zz, qubitsAs2D[row][col], qubitsAs2D[row][col + 1]);
+            }
+
+            // Horizontal interactions between "odd" pairs
+            for col in 1..2..N2-2 {
+                Rzz(2.0 * theta_zz, qubitsAs2D[row][col], qubitsAs2D[row][col + 1]);
+            }
+        }
+
+        // Vertical two-qubit interactions
+        for col in 0..N2-1 {
+
+            // Vertical interactions between "even" pairs
+            for row in 0..2..N1-2 {
+                Rzz(2.0 * theta_zz, qubitsAs2D[row][col], qubitsAs2D[row + 1][col]);
+            }
+
+            // Vertical interactions between "odd" pairs
+            for row in 1..2..N1-2 {
+                Rzz(2.0 * theta_zz, qubitsAs2D[row][col], qubitsAs2D[row + 1][col]);
+            }
+
+        }
+
+    }
+
+    MResetEachZ(qubits)
+}
+ 
\ No newline at end of file


Test patch:
diff --git a/compiler/qsc/src/interpret/tests.rs b/compiler/qsc/src/interpret/tests.rs
index ce372544..2b82256c 100644
--- a/compiler/qsc/src/interpret/tests.rs
+++ b/compiler/qsc/src/interpret/tests.rs
@@ -46,6 +46,21 @@ mod given_interpreter {
         (result, receiver.dump())
     }
 
+    fn invoke(
+        interpreter: &mut Interpreter,
+        callable: &str,
+        args: Value,
+    ) -> (InterpretResult, String) {
+        let mut cursor = Cursor::new(Vec::<u8>::new());
+        let mut receiver = CursorReceiver::new(&mut cursor);
+        let callable = match interpreter.eval_fragments(&mut receiver, callable) {
+            Ok(val) => val,
+            Err(e) => return (Err(e), receiver.dump()),
+        };
+        let result = interpreter.invoke(&mut receiver, callable, args);
+        (result, receiver.dump())
+    }
+
     mod without_sources {
         use expect_test::expect;
         use indoc::indoc;
@@ -514,6 +529,166 @@ mod given_interpreter {
             is_only_value(&result, &output, &Value::unit());
         }
 
+        #[test]
+        fn interpreter_without_sources_has_no_items() {
+            let interpreter = get_interpreter();
+            let items = interpreter.user_globals();
+            assert!(items.is_empty());
+        }
+
+        #[test]
+        fn fragment_without_items_has_no_items() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = line(&mut interpreter, "()");
+            is_only_value(&result, &output, &Value::unit());
+            let items = interpreter.source_globals();
+            assert!(items.is_empty());
+        }
+
+        #[test]
+        fn fragment_defining_items_has_items() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = line(
+                &mut interpreter,
+                indoc! {r#"
+                    function Foo() : Int { 2 }
+                    function Bar() : Int { 3 }
+                "#},
+            );
+            is_only_value(&result, &output, &Value::unit());
+            let items = interpreter.source_globals();
+            assert_eq!(items.len(), 2);
+            // No namespace for top-level items
+            assert!(items[0].0.is_empty());
+            expect![[r#"
+                "Foo"
+            "#]]
+            .assert_debug_eq(&items[0].1);
+            // No namespace for top-level items
+            assert!(items[1].0.is_empty());
+            expect![[r#"
+                "Bar"
+            "#]]
+            .assert_debug_eq(&items[1].1);
+        }
+
+        #[test]
+        fn fragment_defining_items_with_namespace_has_items() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = line(
+                &mut interpreter,
+                indoc! {r#"
+                    namespace Foo {
+                        function Bar() : Int { 3 }
+                    }
+                "#},
+            );
+            is_only_value(&result, &output, &Value::unit());
+            let items = interpreter.source_globals();
+            assert_eq!(items.len(), 1);
+            expect![[r#"
+                [
+                    "Foo",
+                ]
+            "#]]
+            .assert_debug_eq(&items[0].0);
+            expect![[r#"
+                "Bar"
+            "#]]
+            .assert_debug_eq(&items[0].1);
+        }
+
+        #[test]
+        fn fragments_defining_items_add_to_existing_items() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = line(
+                &mut interpreter,
+                indoc! {r#"
+                    function Foo() : Int { 2 }
+                    function Bar() : Int { 3 }
+                "#},
+            );
+            is_only_value(&result, &output, &Value::unit());
+            let items = interpreter.source_globals();
+            assert_eq!(items.len(), 2);
+            let (result, output) = line(
+                &mut interpreter,
+                indoc! {r#"
+                    function Baz() : Int { 4 }
+                    function Qux() : Int { 5 }
+                "#},
+            );
+            is_only_value(&result, &output, &Value::unit());
+            let items = interpreter.source_globals();
+            assert_eq!(items.len(), 4);
+            // No namespace for top-level items
+            assert!(items[0].0.is_empty());
+            expect![[r#"
+                "Foo"
+            "#]]
+            .assert_debug_eq(&items[0].1);
+            // No namespace for top-level items
+            assert!(items[1].0.is_empty());
+            expect![[r#"
+                "Bar"
+            "#]]
+            .assert_debug_eq(&items[1].1);
+            // No namespace for top-level items
+            assert!(items[2].0.is_empty());
+            expect![[r#"
+                "Baz"
+            "#]]
+            .assert_debug_eq(&items[2].1);
+            // No namespace for top-level items
+            assert!(items[3].0.is_empty());
+            expect![[r#"
+                "Qux"
+            "#]]
+            .assert_debug_eq(&items[3].1);
+        }
+
+        #[test]
+        fn invoke_callable_without_args_succeeds() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = invoke(
+                &mut interpreter,
+                "Std.Diagnostics.DumpMachine",
+                Value::unit(),
+            );
+            is_unit_with_output(&result, &output, "STATE:\nNo qubits allocated");
+        }
+
+        #[test]
+        fn invoke_callable_with_args_succeeds() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = invoke(
+                &mut interpreter,
+                "Message",
+                Value::String("Hello, World!".into()),
+            );
+            is_unit_with_output(&result, &output, "Hello, World!");
+        }
+
+        #[test]
+        fn invoke_lambda_with_capture_succeeds() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = line(&mut interpreter, "let x = 1; let f = y -> x + y;");
+            is_only_value(&result, &output, &Value::unit());
+            let (result, output) = invoke(&mut interpreter, "f", Value::Int(2));
+            is_only_value(&result, &output, &Value::Int(3));
+        }
+
+        #[test]
+        fn invoke_lambda_with_capture_in_callable_expr_succeeds() {
+            let mut interpreter = get_interpreter();
+            let (result, output) = invoke(
+                &mut interpreter,
+                "{let x = 1; let f = y -> x + y; f}",
+                Value::Int(2),
+            );
+            is_only_value(&result, &output, &Value::Int(3));
+        }
+
         #[test]
         fn callables_failing_profile_validation_are_not_registered() {
             let mut interpreter =
@@ -1698,6 +1873,46 @@ mod given_interpreter {
             );
         }
 
+        #[test]
+        fn interpreter_returns_items_from_source() {
+            let sources = SourceMap::new(
+                [(
+                    "test".into(),
+                    "namespace A {
+                        operation B(): Unit { }
+                    }
+                    "
+                    .into(),
+                )],
+                Some("A.B()".into()),
+            );
+
+            let (std_id, store) =
+                crate::compile::package_store_with_stdlib(TargetCapabilityFlags::all());
+            let interpreter = Interpreter::new(
+                sources,
+                PackageType::Lib,
+                TargetCapabilityFlags::all(),
+                LanguageFeatures::default(),
+                store,
+                &[(std_id, None)],
+            )
+            .expect("interpreter should be created");
+
+            let items = interpreter.user_globals();
+            assert_eq!(1, items.len());
+            expect![[r#"
+                [
+                    "A",
+                ]
+            "#]]
+            .assert_debug_eq(&items[0].0);
+            expect![[r#"
+                "B"
+            "#]]
+            .assert_debug_eq(&items[0].1);
+        }
+
         #[test]
         fn interpreter_can_be_created_from_ast() {
             let sources = SourceMap::new(
diff --git a/pip/tests/test_qsharp.py b/pip/tests/test_qsharp.py
index 0779972f..f79541b0 100644
--- a/pip/tests/test_qsharp.py
+++ b/pip/tests/test_qsharp.py
@@ -3,6 +3,7 @@
 
 import pytest
 import qsharp
+import qsharp.code
 import qsharp.utils
 from contextlib import redirect_stdout
 import io
@@ -35,6 +36,35 @@ def test_stdout_multiple_lines() -> None:
     assert f.getvalue() == "STATE:\n|0⟩: 1.0000+0.0000𝑖\nHello!\n"
 
 
+def test_captured_stdout() -> None:
+    qsharp.init(target_profile=qsharp.TargetProfile.Unrestricted)
+    f = io.StringIO()
+    with redirect_stdout(f):
+        result = qsharp.eval(
+            '{Message("Hello, world!"); Message("Goodbye!")}', save_events=True
+        )
+    assert f.getvalue() == ""
+    assert len(result["messages"]) == 2
+    assert result["messages"][0] == "Hello, world!"
+    assert result["messages"][1] == "Goodbye!"
+
+
+def test_captured_matrix() -> None:
+    qsharp.init(target_profile=qsharp.TargetProfile.Unrestricted)
+    f = io.StringIO()
+    with redirect_stdout(f):
+        result = qsharp.eval(
+            "Std.Diagnostics.DumpOperation(1, qs => H(qs[0]))",
+            save_events=True,
+        )
+    assert f.getvalue() == ""
+    assert len(result["matrices"]) == 1
+    assert (
+        str(result["matrices"][0])
+        == "MATRIX:\n 0.7071+0.0000𝑖 0.7071+0.0000𝑖\n 0.7071+0.0000𝑖 −0.7071+0.0000𝑖"
+    )
+
+
 def test_quantum_seed() -> None:
     qsharp.init(target_profile=qsharp.TargetProfile.Unrestricted)
     qsharp.set_quantum_seed(42)
@@ -257,6 +287,7 @@ def test_dump_operation() -> None:
             else:
                 assert res[i][j] == complex(0.0, 0.0)
 
+
 def test_run_with_noise_produces_noisy_results() -> None:
     qsharp.init()
     qsharp.set_quantum_seed(0)
@@ -273,6 +304,7 @@ def test_run_with_noise_produces_noisy_results() -> None:
     )
     assert result[0] > 5
 
+
 def test_compile_qir_input_data() -> None:
     qsharp.init(target_profile=qsharp.TargetProfile.Base)
     qsharp.eval("operation Program() : Result { use q = Qubit(); return M(q) }")
@@ -324,7 +356,7 @@ def test_run_with_result_callback(capsys) -> None:
     results = qsharp.run("Foo()", 3, on_result=on_result, save_events=True)
     assert (
         str(results)
-        == "[{'result': Zero, 'events': [Hello, world!]}, {'result': Zero, 'events': [Hello, world!]}, {'result': Zero, 'events': [Hello, world!]}]"
+        == "[{'result': Zero, 'events': [Hello, world!], 'messages': ['Hello, world!'], 'matrices': [], 'dumps': []}, {'result': Zero, 'events': [Hello, world!], 'messages': ['Hello, world!'], 'matrices': [], 'dumps': []}, {'result': Zero, 'events': [Hello, world!], 'messages': ['Hello, world!'], 'matrices': [], 'dumps': []}]"
     )
     stdout = capsys.readouterr().out
     assert stdout == ""
@@ -373,3 +405,207 @@ def test_target_profile_from_str_match_enum_values() -> None:
     assert qsharp.TargetProfile.from_str(str_value) == target_profile
     with pytest.raises(ValueError):
         qsharp.TargetProfile.from_str("Invalid")
+
+
+def test_callables_exposed_into_env() -> None:
+    qsharp.init()
+    qsharp.eval("function Four() : Int { 4 }")
+    assert qsharp.code.Four() == 4, "callable should be available"
+    qsharp.eval("function Add(a : Int, b : Int) : Int { a + b }")
+    assert qsharp.code.Four() == 4, "first callable should still be available"
+    assert qsharp.code.Add(2, 3) == 5, "second callable should be available"
+    # After init, the callables should be cleared and no longer available
+    qsharp.init()
+    with pytest.raises(AttributeError):
+        qsharp.code.Four()
+
+
+def test_callable_exposed_into_env_complex_types() -> None:
+    qsharp.eval(
+        "function Complicated(a : Int, b : (Double, BigInt)) : ((Double, BigInt), Int) { (b, a) }"
+    )
+    assert qsharp.code.Complicated(2, (3.0, 4000000000000000000000)) == (
+        (3.0, 4000000000000000000000),
+        2,
+    ), "callables that take complex types should marshall them correctly"
+
+
+def test_callable_exposed_into_env_with_array() -> None:
+    qsharp.init()
+    qsharp.eval("function Smallest(a : Int[]) : Int { Std.Math.Min(a)}")
+    assert (
+        qsharp.code.Smallest([1, 2, 3, 0, 4, 5]) == 0
+    ), "callable that takes array should work"
+
+
+def test_callable_with_int_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Int) : Int { a }")
+    assert qsharp.code.Identity(4) == 4
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(OverflowError):
+        qsharp.code.Identity(4000000000000000000000)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_double_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Double) : Double { a }")
+    assert qsharp.code.Identity(4.0) == 4.0
+    assert qsharp.code.Identity(4) == 4.0
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_bigint_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : BigInt) : BigInt { a }")
+    assert qsharp.code.Identity(4000000000000000000000) == 4000000000000000000000
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+
+
+def test_callable_with_string_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : String) : String { a }")
+    assert qsharp.code.Identity("4") == "4"
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_bool_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Bool) : Bool { a }")
+    assert qsharp.code.Identity(True) == True
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4])
+
+
+def test_callable_with_array_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : Int[]) : Int[] { a }")
+    assert qsharp.code.Identity([4, 5, 6]) == [4, 5, 6]
+    assert qsharp.code.Identity([]) == []
+    assert qsharp.code.Identity((4, 5, 6)) == [4, 5, 6]
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([1, 2, 3.0])
+
+
+def test_callable_with_tuple_exposed_into_env_fails_incorrect_types() -> None:
+    qsharp.init()
+    qsharp.eval("function Identity(a : (Int, Double)) : (Int, Double) { a }")
+    assert qsharp.code.Identity((4, 5.0)) == (4, 5.0)
+    assert qsharp.code.Identity((4, 5)) == (4, 5.0)
+    assert qsharp.code.Identity([4, 5.0]) == (4, 5.0)
+    with pytest.raises(qsharp.QSharpError):
+        qsharp.code.Identity((4, 5, 6))
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity("4")
+    with pytest.raises(TypeError):
+        qsharp.code.Identity(4.0)
+    with pytest.raises(TypeError):
+        qsharp.code.Identity([4.0, 5])
+
+
+def test_callables_in_namespaces_exposed_into_env_submodules_and_removed_on_reinit() -> (
+    None
+):
+    qsharp.init()
+    # callables should be created with their namespaces
+    qsharp.eval("namespace Test { function Four() : Int { 4 } }")
+    qsharp.eval("function Identity(a : Int) : Int { a }")
+    # should be able to import callables from env and namespace submodule
+    from qsharp.code import Identity
+    from qsharp.code.Test import Four
+
+    assert Identity(4) == 4
+    assert Four() == 4
+    qsharp.init()
+    # namespaces should be removed
+    with pytest.raises(AttributeError):
+        qsharp.code.Test
+    with pytest.raises(AttributeError):
+        qsharp.code.Identity()
+    # imported callables should fail gracefully
+    with pytest.raises(qsharp.QSharpError):
+        Four()
+
+
+def test_callables_with_unsupported_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval("function Unsupported(a : Int, q : Qubit) : Unit { }")
+    with pytest.raises(qsharp.QSharpError, match="unsupported input type: `Qubit`"):
+        qsharp.code.Unsupported()
+
+
+def test_callables_with_unsupported_types_in_tuples_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval("function Unsupported(q : (Int, Qubit)[]) : Unit { }")
+    with pytest.raises(qsharp.QSharpError, match="unsupported input type: `Qubit`"):
+        qsharp.code.Unsupported()
+
+
+def test_callables_with_unsupported_return_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval('function Unsupported() : Qubit { fail "won\'t be called" }')
+    with pytest.raises(qsharp.QSharpError, match="unsupported output type: `Qubit`"):
+        qsharp.code.Unsupported()
+
+
+def test_callables_with_unsupported_udt_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval("function Unsupported(a : Std.Math.Complex) : Unit { }")
+    with pytest.raises(
+        qsharp.QSharpError, match='unsupported input type: `UDT<"Complex":'
+    ):
+        qsharp.code.Unsupported()
+
+
+def test_callable_with_unsupported_udt_return_types_raise_errors_on_call() -> None:
+    qsharp.init()
+    qsharp.eval('function Unsupported() : Std.Math.Complex { fail "won\'t be called" }')
+    with pytest.raises(
+        qsharp.QSharpError, match='unsupported output type: `UDT<"Complex":'
+    ):
+        qsharp.code.Unsupported()
+
+
+def test_struct_call_constructor_not_exposed_into_env() -> None:
+    qsharp.init()
+    qsharp.eval("struct CustomUDT { a : Int }")
+    with pytest.raises(AttributeError):
+        qsharp.code.CustomUDT
+
+
+def test_lambdas_not_exposed_into_env() -> None:
+    qsharp.init()
+    qsharp.eval("a -> a + 1")
+    assert not hasattr(qsharp.code, "<lambda>")
+    qsharp.eval("q => I(q)")
+    assert not hasattr(qsharp.code, "<lambda>")
diff --git a/samples_test/src/tests/algorithms.rs b/samples_test/src/tests/algorithms.rs
index f2d59688..413743f0 100644
--- a/samples_test/src/tests/algorithms.rs
+++ b/samples_test/src/tests/algorithms.rs
@@ -254,6 +254,10 @@ pub const SHOR_EXPECT_DEBUG: Expect = expect![[r#"
     Found factor=13
     Found factorization 143 = 13 * 11
     (13, 11)"#]];
+pub const SIMPLEISING_EXPECT: Expect =
+    expect!["[Zero, Zero, Zero, One, One, Zero, One, One, Zero]"];
+pub const SIMPLEISING_EXPECT_DEBUG: Expect =
+    expect!["[Zero, Zero, Zero, One, One, Zero, One, One, Zero]"];
 pub const SUPERDENSECODING_EXPECT: Expect = expect!["((false, true), (false, true))"];
 pub const SUPERDENSECODING_EXPECT_DEBUG: Expect = expect!["((false, true), (false, true))"];
 pub const SUPERPOSITION_EXPECT: Expect = expect!["Zero"];