Welcome to another edition of Toolkit Tuesdays! In this series, I’ll be highlighting some of the controls and helpers in the Uno Toolkit library. This library is a collection of controls and helpers that we’ve created to make life easier when building apps with Uno Platform. I hope you find them useful too!
This week we’re covering the ZoomContentControl. The name pretty much explains it: it’s a control that lets you zoom and pan whatever content you put inside it. It handles the scaling, the panning, and even draws its own scroll bars, so you can focus on your content instead of the plumbing.
As always, these components need a little extra setup since they’re part of the Uno Toolkit library. You can refer to the Getting Started documentation to get everything wired up.
Say you’ve got a big, detailed image. A floor plan, a schematic, a map, a scanned document, a giant chart. Whatever it is, it doesn’t fit on screen at full size, and shrinking it down to fit makes it useless because nobody can read the details. We’ve all shipped that screen where the user squints and pinches at a blurry blob and quietly hates you for it.
The instinct is to reach for a ScrollViewer, and a ScrollViewer will happily let you scroll around content that’s bigger than the viewport. It even has ZoomMode, MinZoomFactor, and MaxZoomFactor properties inherited from WinUI, so you’d be forgiven for thinking you’re done. The trouble is that the built-in zoom story leans on pinch gestures and doesn’t give you a clean, consistent way to drive the zoom level from code across every platform Uno targets. The moment you want a “Zoom In” button, a “Reset” button, or a “fit this to the screen” behavior, you’re back to writing a pile of plumbing yourself.
That plumbing is exactly what the ZoomContentControl hands you for free.
ZoomContentControlAt its core, the ZoomContentControl is a ContentControl. You drop it into your XAML, give it some Content, and it takes over the job of scaling and translating that content within its own bounds:
Under the hood it isn’t a real ScrollViewer at all. It simulates one using a ScaleTransform (that’s your ZoomLevel) and a TranslateTransform (that’s your pan offset), then draws its own scroll bars on top. That detail matters because it’s why the zoom behaves identically whether you’re on Windows, WebAssembly, or a Skia desktop head. No per-platform “why does this feel weird on WASM” rabbit holes, which I’ve been down more than once.
Add the utu namespace and wrap your content:
1
2
3
4
5
6
7
8
xmlns:utu="using:Uno.Toolkit.UI"
...
<utu:ZoomContentControl MinZoomLevel="0.5"
MaxZoomLevel="4">
<Image Source="ms-appx:///Assets/large-map.png"
Stretch="Uniform" />
</utu:ZoomContentControl>
That’s genuinely all it takes to get something zoomable. No code-behind, no gesture wrangling, nothing. :ok_hand:
Out of the box you get these interactions on desktop:
For any of that to work, the control needs to be loaded and visible, IsActive needs to be true (it is by default), and the relevant IsZoomAllowed / IsPanAllowed flags need to be true (also both default to true).
A quick note on expectations, because I don’t want you to be surprised: the built-in interactions above are mouse- and keyboard-driven. There’s no automatic pinch-to-zoom or double-tap gesture baked in, so if you’re targeting touch you’ll want to drive the ZoomLevel yourself with some buttons. Which, conveniently, is exactly what we’re about to do.
The most useful thing about this control is that ZoomLevel is just a plain double dependency property. Want a “Zoom In” button? Nudge the number:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<StackPanel>
<utu:ZoomContentControl x:Name="ZoomContent"
Width="400"
Height="300"
ZoomLevel="1"
MinZoomLevel="0.5"
MaxZoomLevel="10"
IsZoomAllowed="True"
IsPanAllowed="True">
<Border BorderBrush="White"
BorderThickness="2"
Padding="10">
<Image Source="ms-appx:///Assets/UnoLogo.png"
Width="75"
Height="101" />
</Border>
</utu:ZoomContentControl>
<StackPanel Orientation="Horizontal"
Spacing="12"
HorizontalAlignment="Center">
<Button x:Name="ZoomInButton" Content="Zoom In" />
<Button x:Name="ZoomOutButton" Content="Zoom Out" />
<Button x:Name="ResetButton" Content="Reset" />
</StackPanel>
</StackPanel>
And the code-behind is about as simple as it gets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void OnZoomInClick(object sender, RoutedEventArgs e)
{
if (ZoomContent.ZoomLevel < ZoomContent.MaxZoomLevel)
{
ZoomContent.ZoomLevel += 0.2;
}
}
private void OnZoomOutClick(object sender, RoutedEventArgs e)
{
if (ZoomContent.ZoomLevel > ZoomContent.MinZoomLevel)
{
ZoomContent.ZoomLevel -= 0.2;
}
}
private void OnResetClick(object sender, RoutedEventArgs e)
{
ZoomContent.ResetViewport();
}
And that’s the whole trick. Notice I’m checking against MinZoomLevel and MaxZoomLevel before nudging the value. The control will happily clamp things for you, but guarding the button logic keeps the intent obvious for whoever reads this next (usually me, six months from now, having forgotten I wrote it). If you’re doing this in an MVVM or MVUX app, ZoomLevel binds just as happily to a view model property.
That ResetButton above calls ResetViewport(), which is one of a handful of convenience methods the control exposes. Here’s the full set and what each one actually does:
| Method | What it does |
|---|---|
ResetZoom() |
Sets ZoomLevel back to 1 |
ResetScroll() |
Resets the scroll/pan position to (0, 0) |
ResetViewport() |
Does both of the above, back to 100% and re-centered |
CenterContent() |
Centers the content within the viewport |
FitToCanvas() |
Adjusts ZoomLevel so the content fills the available space |
FitToCanvas() is the one I reach for most. If you’ve got a document or a diagram and you want the classic “fit to page” behavior, that’s your button. And if you’d rather it happen automatically whenever the content or viewport size changes? You don’t even need the button, as we’ll see next.
Two boolean properties handle the automatic cases:
1
2
3
4
<utu:ZoomContentControl AutoFitToCanvas="True"
AutoCenterContent="True">
<Image Source="ms-appx:///Assets/large-map.png" />
</utu:ZoomContentControl>
AutoFitToCanvas (defaults to false) calls FitToCanvas() for you whenever the content or viewport resizes, so your content always starts out fully visibleAutoCenterContent (defaults to true) keeps the content centered in the viewportThat’s a lovely pairing for something like an image viewer that should always open showing the whole image, then let the user zoom in from there. Two properties, zero code-behind. I’ll take it.
A few more properties let you dial in the experience:
ScaleWheelRatio controls how much the zoom factor changes per mouse wheel tick. It’s a small number by default (0.0006), so if scrolling feels too slow or too twitchy, this is your knob.PanWheelRatio does the same thing for how far a wheel tick pans (defaults to 0.25).AdditionalMargin adds an unscaled Thickness of breathing room around the content, so you can pan a little past the edges instead of having the content jammed right up against the viewport border.AllowFreePanning (defaults to true) lets the content be panned outside the viewport bounds rather than being locked in.ScrollBarLayout lets you change how the simulated scroll bars are laid out.None of these are required reading (the defaults are sensible), but they’re there when you need to make the interaction feel right for your particular content.
The last property worth calling out is ElementOnFocus. Point it at a child FrameworkElement and the control’s auto-zoom and auto-fit behavior will center around that element rather than the content as a whole. There’s a matching SetLocalFocus(...) / ClearLocalFocus() pair in code if you want to drive it dynamically, which is handy for something like “zoom to this node in a diagram” where the focus target changes as the user clicks around.
One heads-up if you go digging through the reference material, because it tripped me up: an older how-to snippet mentions ZoomTo(...) and ZoomToRect(...) methods. Those aren’t part of the control as it ships today, so don’t go hunting for them like I did. Set the ZoomLevel property directly (or use FitToCanvas() / ResetViewport()) and you’ll get where you’re going. I’ve flagged it so the docs can catch up.
The ZoomContentControl is one of those controls that looks small but quietly saves you a real chunk of work. Any time you’ve got content that’s bigger than the space you can give it (maps, floor plans, schematics, big images, documents), this is the thing that turns it into a proper zoom-and-pan experience, with a ZoomLevel you can bind and a set of reset/fit helpers that cover the common buttons you’d otherwise be writing by hand.
You can play with it right now in the Uno Toolkit Samples app, which has a live ZoomContentControl page you can poke at across every platform.
I hope you enjoyed this edition of Toolkit Tuesdays! As always, I encourage you to consult the full documentation using the links below, and to jump into the fun on the Uno Toolkit GitHub repo if you find a bug, want to make an improvement, or want to help the docs along.
Maintaining a healthy Android ecosystem is a shared commitment where every app and game has a role to play. To help you deliver the premium experiences users expect, Google Play is introducing two new quality requirements: one focused on reducing app memory footprint, and another on providing a secure, seamless device migration experience.
First, to help developers navigate industry-wide hardware constraints and Android's broader memory limits, Google Play is establishing new performance thresholds.
Second, as part of our broader commitment to elevate app quality, we are introducing a new onboarding standard to simplify and secure login during device upgrades.
The mobile industry is navigating significant hardware supply constraints that are altering device memory availability that over time can negatively impact the user experience. Android is addressing this challenge head-on with broader memory limits that aim to protect the overall user experience from apps using excess memory and causing system-wide slowdowns.
Building on this, today Google Play is establishing performance thresholds to help developers ensure their apps continue to deliver the premium experience users expect. This includes new thresholds across dynamic memory usage, bitmap usage, and code optimization to prevent unexpected on-device performance throttling and app terminations.
Review the thresholds and technical details to better understand applicability differences specific to apps and games, RAM buckets, and process states.
To enable you to proactively discover, investigate, and optimize your app or game to meet the new bad behavior thresholds, we’ve already begun rolling out new tools in Play Console to get you started.
Later this year, you can expect additional diagnostic tools, including metrics on how long your app spends in each state and deeper insights into the Android Memory Limiter, a feature that prevents individual apps from using too much device memory. Through our ongoing investment in these enhancements, our goal is to help you continuously optimize your footprint and elevate the experience you provide your users.
Starting in February 2027, apps and games must meet their respective bad behavior thresholds for Memory usage (Anonymous RSS + Swap), Bitmap memory usage and DEX code optimization. Similar to existing Android vitals metrics, exceeding thresholds is a strong indicator of degraded app experiences and on-device Android app terminations.
Apps and games that do not meet these thresholds may see reduced app visibility and publishing capabilities on Google Play. Additional details will be provided later this year.
Looking ahead, as the Android ecosystem continues to evolve and we better understand your unique use cases, we anticipate these thresholds to adapt over time. Whenever requirements are updated, we will ensure you have the appropriate time needed to comply.
When users switch to a new device, moving their apps over should be secure and effortless. To provide a better onboarding experience, we’re introducing a requirement for app developers to make log-ins faster and safer during device transfers.
The Zero-Tap Sign-In standard will require any app supporting user sign-in, optional or mandatory, to automatically restore a user's sign-in state when they move from one Android device to another with the Android Restore Credentials API. This API ensures that when a user opens your app on their new Android device for the very first time, they are instantly recognized and securely signed in without additional taps.
Starting in April 2027, Google Play will require apps to meet the Zero Tap Sign-In requirement to maintain full publishing capabilities and optimal visibility in the Play Store.
While games are currently exempt from the Zero-Tap Sign-In requirement, developers should expect dedicated guidance and tailored solutions for complex gaming authentication use cases coming in 2027. For games who support single-account sign-in, we strongly encourage usage of the Restore Credentials API to support zero-tap sign-in. Please visit our help center for more information.
Start preparing for the upcoming enforcement deadlines by reviewing the details of each requirement:
Meeting these quality requirements on Google Play is a crucial step toward building a faster, more reliable experience for our users. We appreciate your partnership and everything you do to keep the Android community thriving.
A step-by-step guide to grounding a LangGraph agent in Microsoft Foundry IQ agentic retrieval — without rebuilding your RAG pipeline.
If you build agents on LangGraph and your enterprise content lives in Azure, you have probably written the same code twice: a chunker, an embedding job, a vector store, a retriever, a reranker, and a permissions filter bolted on at the end. Every new agent gets its own copy. Every copy drifts.
Learn about nodes, edges and state in LangGraph, plus how to build a LangGraph app in TypeScript.
LangGraph is a framework for building AI agents and workflows as stateful graphs. Each step in the workflow is represented as a node. Edges define how execution flows between nodes, and state acts as shared memory throughout the graph.
Built on top of LangChain, LangGraph provides explicit and debuggable control over multi-step LLM applications such as chatbots, tool-calling agents and automated pipelines.
In this article, we will use TypeScript to build a simple LangGraph application from scratch and explore the core concepts, APIs and execution model that form the foundation of more advanced agentic workflows.
LangGraph has three building blocks.

State represents data that is shared by every node. A node represents a function that takes a state as input and returns an updated value for the state. Edges are control flow, which can be serial, parallel or conditional.
To understand how graphs work, we’ll start with a simple counter application that does not require a model. This example will introduce the core concepts of state and node, and by the end of the section, you’ll understand how they form the foundation of a graph.
At the end of the article, we will create a FIFA chatbot using an OpenAI model and LangGraph using nodes, states and edges.
In LangGraph, a state is a data structure that serves as a shared memory layer that connects the graphs. Nodes consume state, produce incremental updates and rely on it for conditional routing.
Every node in the graph depends on the state. Nodes read data from the state and write partial updates back to it. And conditional edges use the state to determine the next path in the workflow.
StateSchema is the contract that defines the structure and behavior of a state in LangGraph. More than just a TypeScript type, it serves as a runtime specification that determines:
You can create a state using the StateSchema as shown below:
counter-state.schema.ts
import { ReducedValue, StateSchema } from "@langchain/langgraph";
import { z } from "zod";
export const CounterStateSchema = new StateSchema({
count: new ReducedValue(z.number().default(0), {
inputSchema: z.number(),
reducer: (current, next) => current + next,
}),
});
Every key in a StateSchema can be one of the following:
count is a ReducedValue, so each update is merged with the current count rather than overwriting it.ReducedValue designed for chat applications. Instead of replacing existing messages, new messages are automatically appended to the conversation history.Once state is defined using the StateSchema, you can work with the three derived types:
counter-state.types.ts
export type CounterState = typeof CounterStateSchema.State;
export type CounterUpdate = typeof CounterStateSchema.Update;
export type CounterNode = typeof CounterStateSchema.Node;
For this example, we will mostly work with the CounterNode. Also, the CounterStateSchema has:
Before we move further, let’s discuss the reducer function. It is a simple function that decides how a state field changes when a node returns an update. Multiple nodes can update the same field during a graph execution. Instead of overwriting the existing value, the reducer merges each partial update into the current state.
As nodes returns partial updates, LangGraph would need a rule for conflicting updates. That merge rule is written inside the the reducer function.
reducer: (current, next) => current + next,
The signature of the reducer:
current – value in state before this updatenext – what the node returned for this fieldreturn value – new stored valueUse a reducer only when values need to be combined or accumulated. If each update should simply replace the previous value, a standard field is all you need. As shown in below state schema, each time state will be updated with the last returned value from the node.
lastcount-state.schema.ts
export const LastCountStateSchema = new StateSchema({
count: z.number().default(0),
});
For LastCountState, derived states can be exported as shown below:
lastcount-state.types.ts
export type LastCountState = typeof LastCountStateSchema.State;
export type LastCountUpdate = typeof LastCountStateSchema.Update;
export type LastCountNode = typeof LastCountStateSchema.Node;
So far, we have defined two state schemas and exported their corresponding TypeScript types. The CounterState uses a reducer to accumulate new values with the existing value, while the LastCountState follows a last-write-wins approach and always stores the most recent value. Next, let’s create the nodes that will operate on these states.
In LangGraph, a node is the fundamental building block of a graph. It is usually implemented as a function that takes the current state as input, performs some computation or action, and returns a partial update to the state.
Depending on how the state schema is defined, LangGraph either merges the returned update into the existing state using the configured reducer or replaces the current value with the latest one before passing the updated state to the next step in the workflow.

You can create nodes to increment a counter state by 1 and 2 like below:
counter.node.ts
export const incrementNode: CounterNode = () => ({
count: 1,
});
export const incrementTwiceNode: CounterNode = () => ({
count: 2,
});
After each node runs, LangGraph:
StateSchemaFor LastCountState, a node can be created as shown below:
export const setOneNode: LastCountNode = () => ({
count: 1,
});
export const setFiveNode: LastCountNode = () => ({
count: 5,
});
export const setNineNode: LastCountNode = () => ({
count: 9,
});
Because LastCountState stores only the latest value, every node overwrites the previous value, and the next node receives the most recent state update.
As we discussed earlier, a node only returns a partial state update. How that update is applied to the state is determined by the StateSchema through its merge strategy (reducers or last-write-wins), not by the node’s implementation.
Also, nodes do not decide where execution goes next; edges do. Nodes only read state and write updates.
There are four types of nodes:
All the nodes we have created so far for both CounterState and LastCountState are synchronous and stateless.
We can create a stateful node on CounterState as shown below:
export const doubleCountNode: CounterNode = (state) => ({
count: state.count,
});
We have created nodes; now see how they are connected using edges.
An edge connects two nodes and determines the control flow by specifying which node executes after the current node completes.
An edge can be of two types:
To work with nodes and edges, LangGraph provides two special nodes:
The LangGraph graph is where you assemble everything. A graph brings together the state schema, nodes and edges into an executable workflow. You create and run a graph following the steps:

Think of the graph as the orchestrator, the nodes as workers performing tasks, and the state as the shared memory that connects them.
There are three steps to build the graph:
StateGraph with your schema.compile() to get a runnable graphWe can create a graph for CounterState as shown below:
counter.graph.ts
export function buildCounterGraph() {
return new StateGraph(CounterStateSchema)
.addNode("increment", incrementNode)
.addNode("incrementTwice", incrementTwiceNode)
.addNode("doubleCount", doubleCountNode)
.addEdge(START, "increment")
.addEdge("increment", "incrementTwice")
.addEdge("incrementTwice", "doubleCount")
.addEdge("doubleCount", END)
.compile();
}
As you can see:
addNode() adds a node to the graph. Each node is a step.addEdge() is used to add an edge to the graph. Edges know the step by the string name, not the node function name.new StateGraph() creates a graph bound to the state schema definition..compile() validates the graph. It checks for:In the same way, you can create a graph for LastCountState:
lastcount.graph.ts
export function buildLastCountGraph() {
return new StateGraph(LastCountStateSchema)
.addNode("setOne", setOneNode)
.addNode("setFive", setFiveNode)
.addNode("setNine", setNineNode)
.addEdge(START, "setOne")
.addEdge("setOne", "setFive")
.addEdge("setFive", "setNine")
.addEdge("setNine", END)
.compile();
}
We use addEdge() to connect nodes and define the execution flow within the graph. Key characteristics of addEdge() are:
addEdge(startKey, endKey) creates an unconditional edge.startKey node completes, execution always proceeds to the endKey node.startKey are START or node name or node name [].endKey are END or node name.In the count graph, we start at the increment node and create a linear chain to the doubleCount node.
.addEdge(START, "increment")
.addEdge("increment", "incrementTwice")
.addEdge("incrementTwice", "doubleCount")
.addEdge("doubleCount", END)
This is a linear chain.

When building a LangGraph workflow, keep the following rules in mind:
addNode(), not the underlying function name.addEdge() does not evaluate the graph state or any conditions. Execution always follows the defined path. If you need conditional routing (for example, “if count > 5 go here, otherwise go there”), use addConditionalEdges(). We’ll explore conditional routing in the next article.We can invoke the graph using the invoke() method.
const graph = buildCounterGraph();
const result = await graph.invoke({});
console.log(result);
The invoke method returns the final state, and you should see output {count: 6}. There is another method, stream(), to read the state after each node’s execution, and we will cover it in the next article.
In the same way, you can invoke LastCountGraph as shown below:
const graph1 = buildLastCountGraph();
const result1 = await graph1.invoke({});
console.log(result1);
You should get the result {count: 9}.
In this way, you can create a basic LangGraph graph using state, state schema, edges and nodes.
The following diagram brings together all the concepts we have learned so far and illustrates the main building blocks of a LangGraph.

Now that we’ve covered the core LangGraph concepts, let’s put them into practice by building a chat application that answers questions about the FIFA World Cup.
Let us start by defining the state with MessageValue.
const schema = new StateSchema({ messages: MessagesValue });
Next, create the language model for our FIFA World Cup chatbot using OpenAI’s GPT. Before initializing the model, add your OpenAI API key to the .env file.
const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
Next, set the system prompt such that the model only answers about FIFA and does not answer on other topics.
const SYSTEM = `You are a FIFA World Cup expert. ONLY answer questions about the FIFA World Cup (1930–present): winners, hosts, matches, records, players in World Cup context.
For anything else, reply exactly: "I can only help with FIFA World Cup questions."`;
Next, create the node that acts as an agent. It will read messages, call the models and return the model’s new reply. The MessageValue reducer will append the message to the message history.
const agent: typeof schema.Node = async (state) => ({
messages: [await model.invoke(state.messages)],
});
Next, let’s build the graph. In this example, the graph consists of a single node with an incoming START edge and an outgoing END edge.
const graph = new StateGraph(schema)
.addNode("agent", agent)
.addEdge(START, "agent")
.addEdge("agent", END)
.compile();
Then, we seed the state with the SystemMessage:
let state: typeof schema.State = { messages: [new SystemMessage(SYSTEM)] };
Finally, inside the loop, we invoke the graph as below:
state = await graph.invoke({
...state,
messages: [...state.messages, new HumanMessage(question)],
});
Here we are using schema.state, which is the full state graph read and write. Putting everything together, FIFA Chat Bot should look like below:
import { stdin as input, stdout as output } from "node:process";
import * as readline from "node:readline/promises";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";
import { END, MessagesValue, START, StateGraph, StateSchema } from "@langchain/langgraph";
import "dotenv/config";
const schema = new StateSchema({ messages: MessagesValue });
const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const SYSTEM = `You are a FIFA World Cup expert. ONLY answer questions about the FIFA World Cup (1930–present): winners, hosts, matches, records, players in World Cup context.
For anything else, reply exactly: "I can only help with FIFA World Cup questions."`;
const agent: typeof schema.Node = async (state) => ({
messages: [await model.invoke(state.messages)],
});
const graph = new StateGraph(schema)
.addNode("agent", agent)
.addEdge(START, "agent")
.addEdge("agent", END)
.compile();
let state: typeof schema.State = { messages: [new SystemMessage(SYSTEM)] };
const rl = readline.createInterface({ input, output });
console.log("⚽ FIFA World Cup Chat — type 'exit' to quit\n");
while (true) {
const question = (await rl.question("You: ")).trim();
if (!question) continue;
if (question === "exit" || question === "quit") break;
state = await graph.invoke({
...state,
messages: [...state.messages, new HumanMessage(question)],
});
console.log(`\nAssistant: ${state.messages.at(-1)?.content}\n`);
}
rl.close();
You have created a FIFA chatbot using Graph, which uses nodes, state and edges. In further articles, we go deeper into creating agents using LangGraph. I hope you find this article useful. Thanks for reading.
ClientMode::Empty now disables built-in skills by defaultClientMode::Empty now applies deny-by-default isolation to runtime-bundled skills in addition to other built-in capabilities. includedBuiltinSkills defaults to [] in Empty mode; pass an explicit allowlist to re-enable specific skills. This behavior is consistent across all six SDKs. (#2410)
// Node — empty mode: built-in skills excluded by default
const session = await client.createSession({ mode: ClientMode.Empty });
// opt back in:
const session = await client.createSession({ mode: ClientMode.Empty, includedBuiltinSkills: ["edit"] });// C#
var session = await client.CreateSessionAsync(new SessionOptions { Mode = ClientMode.Empty });
// opt back in:
var session = await client.CreateSessionAsync(new SessionOptions { Mode = ClientMode.Empty, IncludedBuiltinSkills = ["edit"] });# Python
session = await client.create_session(mode=ClientMode.EMPTY)
# opt back in:
session = await client.create_session(mode=ClientMode.EMPTY, included_builtin_skills=["edit"])// Go
session, err := client.CreateSession(ctx, copilot.SessionOptions{Mode: copilot.ClientModeEmpty})
// opt back in:
session, err := client.CreateSession(ctx, copilot.SessionOptions{Mode: copilot.ClientModeEmpty, IncludedBuiltinSkills: []string{"edit"}})Generated by Release Changelog Generator · sonnet46 28.6 AIC · ⌖ 4.12 AIC · ⊞ 8.1K